ЧТМ:Расширения/BatchTools/1.45/0.1
Перейти к навигации
Перейти к поиску
## [0.1.0] — 2026-05-24
### Добавлено
- Создана служебная страница `Special:BatchTools` (с поддержкой ЧПУ-алиаса «Пакетные инструменты» на русском языке).
- Реализована вкладка «Массовое удаление» для быстрой очистки ненужных страниц списком.
- Реализована вкладка «Массовое восстановление» для группового возврата ранее удалённых страниц.
- Добавлены новые права доступа: `deletebatch` (для удаления) и `undeletebatch` (для восстановления).
- Интегрирована локализация интерфейса на английском (`en`) и русском (`ru`) языках.
### Безопасность и ограничения
- Добавлена проверка прав пользователя (Authority) перед выполнением любых операций.
- Реализована защита от межсайтовой подделки запросов (CSRF) с помощью механизма Edit Token ядра MediaWiki.
- Введено ограничение на размер пакета: за одну операцию допускается обрабатывать не более 500 страниц.
- Реализовано экранирование имён страниц при выводе ошибок для предотвращения уязвимостей типа XSS.
BatchTools/
├── extension.json
├── i18n/
│ ├── BatchTools.alias.php
│ ├── en.json
│ └── ru.json
└── includes/
└── SpecialBatchTools.php
extension.json
[править код]{
"name": "BatchTools",
"version": "0.1",
"author": "Diman Russkov",
"descriptionmsg": "batchtools-desc",
"type": "specialpage",
"manifest_version": 2,
"requires": {
"MediaWiki": ">= 1.45.0"
},
"AvailableRights": [
"deletebatch",
"undeletebatch"
],
"SpecialPages": {
"BatchTools": "MediaWiki\\Extension\\BatchTools\\SpecialBatchTools"
},
"AutoloadNamespaces": {
"MediaWiki\\Extension\\BatchTools\\": "includes/"
},
"MessagesDirs": {
"BatchTools": [
"i18n"
]
},
"ExtensionMessagesFiles": {
"BatchToolsAlias": "i18n/BatchTools.alias.php"
}
}
i18n
[править код]BatchTools.alias.php
[править код]<?php
/**
* Aliases for special pages of the BatchTools extension
*/
$specialPageAliases = [];
/** English (English) */
$specialPageAliases['en'] = [
'BatchTools' => [ 'BatchTools' ],
];
/** Russian (Русский) */
$specialPageAliases['ru'] = [
'BatchTools' => [ 'BatchTools', 'Пакетные_инструменты' ],
];
en.json
[править код]{
"@metadata": {
"authors": []
},
"batchtools-desc": "Provides tools for batch operations (mass delete, mass undelete, etc.)",
"batchtools": "Batch tools",
"right-deletebatch": "Mass delete pages",
"action-deletebatch": "mass delete pages",
"right-undeletebatch": "Mass undelete pages",
"action-undeletebatch": "mass undelete pages",
"batchtools-tab-delete": "Mass Delete",
"batchtools-tab-undelete": "Mass Restore",
"batchtools-error-nopermissions": "You do not have permission to access any of the batch tools.",
"batchtools-massdelete-pages": "List of pages (one per line):",
"batchtools-reason": "Reason:",
"batchtools-massdelete-submit": "Delete pages",
"batchtools-massdelete-success": "Successfully deleted $1 pages.",
"batchtools-massdelete-errors": "Failed to delete the following pages:",
"batchtools-massdelete-empty": "The page list is empty or contains invalid names.",
"batchtools-massrestore-pages": "List of pages to restore (one per line):",
"batchtools-massrestore-submit": "Restore pages",
"batchtools-massrestore-success": "Successfully restored $1 pages.",
"batchtools-massrestore-errors": "Failed to restore the following pages:",
"batchtools-massrestore-empty": "The page list is empty or contains invalid names.",
"batchtools-massrestore-norevisions": "The page was not deleted or has already been restored.",
"batchtools-error-limit": "Limit exceeded! Please process no more than {{PLURAL:$1|page|pages|pages}} at a time."
}
ru.json
[править код]{
"@metadata": {
"authors": []
},
"batchtools-desc": "Предоставляет инструменты для массовых операций",
"batchtools": "Массовые инструменты",
"right-deletebatch": "Массовое удаление страниц",
"action-deletebatch": "массово удалять страницы",
"right-undeletebatch": "Массовое восстановление страниц",
"action-undeletebatch": "массово восстанавливать страницы",
"batchtools-tab-delete": "Массовое удаление",
"batchtools-tab-undelete": "Массовое восстановление",
"batchtools-error-nopermissions": "У вас нет прав для доступа к массовым инструментам.",
"batchtools-massdelete-pages": "Список страниц (по одной на строке):",
"batchtools-reason": "Причина:",
"batchtools-massdelete-submit": "Удалить страницы",
"batchtools-massdelete-success": "Успешно удалено страниц: $1.",
"batchtools-massdelete-errors": "Не удалось удалить следующие страницы:",
"batchtools-massdelete-empty": "Список страниц пуст или содержит недопустимые имена.",
"batchtools-massrestore-pages": "Список страниц для восстановления (по одной на строке):",
"batchtools-massrestore-submit": "Восстановить страницы",
"batchtools-massrestore-success": "Успешно восстановлено страниц: $1.",
"batchtools-massrestore-errors": "Не удалось восстановить следующие страницы:",
"batchtools-massrestore-empty": "Список страниц пуст или содержит недопустимые имена.",
"batchtools-massrestore-norevisions": "Страница не была удалена или уже восстановлена.",
"batchtools-error-limit": "Превышен лимит! Пожалуйста, обрабатывайте не более $1 {{PLURAL:$1|страницы|страниц|страниц}} за один раз."
}
includes
[править код]SpecialBatchTools.php
[править код]<?php
namespace MediaWiki\Extension\BatchTools;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Html\Html;
use MediaWiki\Exception\PermissionsError;
use MediaWiki\MediaWikiServices;
use MediaWiki\Title\Title;
use MediaWiki\Status\Status;
class SpecialBatchTools extends SpecialPage {
// Максимальное количество страниц за одну операцию
private const MAX_BATCH_SIZE = 500;
public function __construct() {
parent::__construct( 'BatchTools' );
}
public function isListed() {
$authority = $this->getAuthority();
return $authority->isAllowed( 'deletebatch' ) || $authority->isAllowed( 'undeletebatch' );
}
public function execute( $par ) {
$authority = $this->getAuthority();
if ( !$authority->isAllowed( 'deletebatch' ) && !$authority->isAllowed( 'undeletebatch' ) ) {
throw new PermissionsError( 'deletebatch' );
}
$this->setHeaders();
$this->checkReadOnly();
$out = $this->getOutput();
$out->setPageTitle( $this->msg( 'batchtools' )->text() );
$out->enableOOUI();
$out->addModules( [
'oojs-ui-core',
'oojs-ui-widgets',
'mediawiki.widgets'
] );
$tabs = [];
if ( $authority->isAllowed( 'deletebatch' ) ) {
$tabs['delete'] = $this->msg( 'batchtools-tab-delete' )->text();
}
if ( $authority->isAllowed( 'undeletebatch' ) ) {
$tabs['undelete'] = $this->msg( 'batchtools-tab-undelete' )->text();
}
if ( empty( $tabs ) ) {
$out->addHTML( Html::errorBox( $this->msg( 'batchtools-error-nopermissions' )->text() ) );
return;
}
$request = $this->getRequest();
$view = $request->getVal( 'view' );
if ( $view === null || !array_key_exists( $view, $tabs ) ) {
$view = array_key_first( $tabs );
}
$navHtml = '<div style="margin-bottom: 25px; font-size: 1.1em; border-bottom: 2px solid #a2a9b1; padding-bottom: 10px; display: flex; flex-wrap: wrap; gap: 10px;">';
$tabLinks = [];
foreach ( $tabs as $tabKey => $tabName ) {
if ( $view === $tabKey ) {
$tabLinks[] = '<b style="color: #202122; background: #eaecf0; padding: 5px 10px; border-radius: 2px;">' . htmlspecialchars( $tabName ) . '</b>';
} else {
$url = $this->getPageTitle()->getLocalURL( [ 'view' => $tabKey ] );
$tabLinks[] = Html::element( 'a', [ 'href' => $url, 'style' => 'padding: 5px 10px;' ], $tabName );
}
}
$navHtml .= implode( '<span style="color:#a2a9b1;">|</span>', $tabLinks ) . '</div>';
$out->addHTML( $navHtml );
// Вывод уведомлений об успехе/ошибках
$this->printSessionResult();
if ( $view === 'delete' ) {
$this->showMassDeleteTab();
} elseif ( $view === 'undelete' ) {
$this->showMassRestoreTab();
}
}
/**
* Выводит результаты выполнения массовых операций из сессии
*/
private function printSessionResult() {
$session = $this->getRequest()->getSession();
$result = $session->get( 'batchtools_result' );
if ( $result ) {
$session->remove( 'batchtools_result' );
$html = '';
$action = $result['action'] ?? 'delete';
$msgSuccess = $action === 'delete' ? 'batchtools-massdelete-success' : 'batchtools-massrestore-success';
$msgErrors = $action === 'delete' ? 'batchtools-massdelete-errors' : 'batchtools-massrestore-errors';
if ( !empty( $result['success_count'] ) ) {
$html .= Html::successBox( $this->msg( $msgSuccess, $result['success_count'] )->text() );
}
if ( !empty( $result['errors'] ) ) {
$errHtml = '<b>' . $this->msg( $msgErrors )->text() . '</b><ul style="margin-top: 5px; margin-bottom: 0;">';
foreach ( $result['errors'] as $err ) {
// Абсолютная защита от XSS: все имена страниц экранируются здесь
$errHtml .= '<li>' . htmlspecialchars( $err ) . '</li>';
}
$errHtml .= '</ul>';
$html .= Html::errorBox( $errHtml );
}
if ( $html !== '' ) {
$this->getOutput()->addHTML( '<div style="margin-bottom: 20px;">' . $html . '</div>' );
}
}
}
/**
* Вкладка: Массовое удаление
*/
private function showMassDeleteTab() {
$out = $this->getOutput();
$request = $this->getRequest();
$user = $this->getUser();
$authority = $this->getAuthority();
if ( !$authority->isAllowed( 'deletebatch' ) ) {
$out->addHTML( Html::errorBox( $this->msg( 'batchtools-error-nopermissions' )->text() ) );
return;
}
if ( $request->wasPosted() && $user->matchEditToken( $request->getVal( 'wpEditToken' ) ) && $request->getVal( 'action_mass_delete' ) ) {
$pagesText = $request->getVal( 'pages_list', '' );
$reason = trim( $request->getVal( 'delete_reason', '' ) );
$pages = $this->parseTextareaList( $pagesText );
if ( empty( $pages ) ) {
$out->addHTML( Html::errorBox( $this->msg( 'batchtools-massdelete-empty' )->text() ) );
} elseif ( count( $pages ) > self::MAX_BATCH_SIZE ) {
// Проверка на превышение лимита
$request->getSession()->set( 'batchtools_result', [
'action' => 'delete',
'success_count' => 0,
'errors' => [ $this->msg( 'batchtools-error-limit', self::MAX_BATCH_SIZE )->text() ]
] );
$out->redirect( $this->getPageTitle()->getFullURL( [ 'view' => 'delete' ] ) );
return;
} else {
$successCount = 0;
$errors = [];
$deletePageFactory = MediaWikiServices::getInstance()->getDeletePageFactory();
foreach ( $pages as $pageName ) {
$title = Title::newFromText( $pageName );
if ( !$title || !$title->exists() ) {
$errors[] = "$pageName — страница не существует или недопустимое имя.";
continue;
}
$deletePage = $deletePageFactory->newDeletePage( $title->toPageIdentity(), $authority );
$status = $deletePage->deleteIfAllowed( $reason );
if ( $status->isOK() ) {
$successCount++;
} else {
$statusObj = Status::wrap( $status );
$errors[] = "$pageName — " . $statusObj->getMessage()->text();
}
}
$request->getSession()->set( 'batchtools_result', [
'action' => 'delete',
'success_count' => $successCount,
'errors' => $errors
] );
$out->redirect( $this->getPageTitle()->getFullURL( [ 'view' => 'delete' ] ) );
return;
}
}
$pagesInput = new \OOUI\MultilineTextInputWidget([
'name' => 'pages_list', 'rows' => 10, 'infusable' => true
]);
$reasonInput = new \OOUI\TextInputWidget([
'name' => 'delete_reason', 'infusable' => true
]);
$submitBtn = new \OOUI\ButtonInputWidget([
'type' => 'submit', 'name' => 'action_mass_delete', 'value' => '1',
'label' => $this->msg( 'batchtools-massdelete-submit' )->text(),
'flags' => [ 'primary', 'destructive' ], 'infusable' => true
]);
$formAction = $this->getPageTitle()->getLocalURL( [ 'view' => 'delete' ] );
$html = '<form method="post" action="' . htmlspecialchars( $formAction ) . '">';
$html .= Html::hidden( 'wpEditToken', $user->getEditToken() );
$html .= '<div style="background:#eaecf0; padding:20px; border:1px solid #c8ccd1; border-radius: 2px; max-width: 600px;">';
$html .= '<div style="margin-bottom: 15px;"><b>' . $this->msg( 'batchtools-massdelete-pages' )->text() . '</b><br>' . $pagesInput->toString() . '</div>';
$html .= '<div style="margin-bottom: 20px;"><b>' . $this->msg( 'batchtools-reason' )->text() . '</b><br>' . $reasonInput->toString() . '</div>';
$html .= '<div>' . $submitBtn->toString() . '</div></div></form>';
$out->addHTML( $html );
}
/**
* Вкладка: Массовое восстановление
*/
private function showMassRestoreTab() {
$out = $this->getOutput();
$request = $this->getRequest();
$user = $this->getUser();
$authority = $this->getAuthority();
if ( !$authority->isAllowed( 'undeletebatch' ) ) {
$out->addHTML( Html::errorBox( $this->msg( 'batchtools-error-nopermissions' )->text() ) );
return;
}
if ( $request->wasPosted() && $user->matchEditToken( $request->getVal( 'wpEditToken' ) ) && $request->getVal( 'action_mass_restore' ) ) {
$pagesText = $request->getVal( 'pages_list', '' );
$reason = trim( $request->getVal( 'restore_reason', '' ) );
$pages = $this->parseTextareaList( $pagesText );
if ( empty( $pages ) ) {
$out->addHTML( Html::errorBox( $this->msg( 'batchtools-massrestore-empty' )->text() ) );
} elseif ( count( $pages ) > self::MAX_BATCH_SIZE ) {
// Проверка на превышение лимита
$request->getSession()->set( 'batchtools_result', [
'action' => 'restore',
'success_count' => 0,
'errors' => [ $this->msg( 'batchtools-error-limit', self::MAX_BATCH_SIZE )->text() ]
] );
$out->redirect( $this->getPageTitle()->getFullURL( [ 'view' => 'undelete' ] ) );
return;
} else {
$successCount = 0;
$errors = [];
$undeletePageFactory = MediaWikiServices::getInstance()->getUndeletePageFactory();
foreach ( $pages as $pageName ) {
$title = Title::newFromText( $pageName );
if ( !$title || !$title->canExist() ) {
$errors[] = "$pageName — недопустимое имя страницы.";
continue;
}
$undeletePage = $undeletePageFactory->newUndeletePage( $title->toPageIdentity(), $authority );
$status = $undeletePage->undeleteIfAllowed( $reason );
if ( $status->isOK() ) {
$val = $status->getValue();
$revs = $val['revs'] ?? 0;
$files = $val['files'] ?? 0;
if ( $revs > 0 || $files > 0 ) {
$successCount++;
} else {
$errors[] = "$pageName — " . $this->msg( 'batchtools-massrestore-norevisions' )->text();
}
} else {
$statusObj = Status::wrap( $status );
$errors[] = "$pageName — " . $statusObj->getMessage()->text();
}
}
$request->getSession()->set( 'batchtools_result', [
'action' => 'restore',
'success_count' => $successCount,
'errors' => $errors
] );
$out->redirect( $this->getPageTitle()->getFullURL( [ 'view' => 'undelete' ] ) );
return;
}
}
$pagesInput = new \OOUI\MultilineTextInputWidget([
'name' => 'pages_list', 'rows' => 10, 'infusable' => true
]);
$reasonInput = new \OOUI\TextInputWidget([
'name' => 'restore_reason', 'infusable' => true
]);
$submitBtn = new \OOUI\ButtonInputWidget([
'type' => 'submit', 'name' => 'action_mass_restore', 'value' => '1',
'label' => $this->msg( 'batchtools-massrestore-submit' )->text(),
'flags' => [ 'primary', 'progressive' ], 'infusable' => true
]);
$formAction = $this->getPageTitle()->getLocalURL( [ 'view' => 'undelete' ] );
$html = '<form method="post" action="' . htmlspecialchars( $formAction ) . '">';
$html .= Html::hidden( 'wpEditToken', $user->getEditToken() );
$html .= '<div style="background:#eaf3ff; padding:20px; border:1px solid #36c; border-radius: 2px; max-width: 600px;">';
$html .= '<div style="margin-bottom: 15px;"><b>' . $this->msg( 'batchtools-massrestore-pages' )->text() . '</b><br>' . $pagesInput->toString() . '</div>';
$html .= '<div style="margin-bottom: 20px;"><b>' . $this->msg( 'batchtools-reason' )->text() . '</b><br>' . $reasonInput->toString() . '</div>';
$html .= '<div>' . $submitBtn->toString() . '</div></div></form>';
$out->addHTML( $html );
}
private function parseTextareaList( $text ) {
$lines = explode( "\n", $text );
$result = [];
foreach ( $lines as $line ) {
$line = trim( $line );
if ( $line !== '' ) {
$result[] = $line;
}
}
return array_unique( $result );
}
}
| BatchTools | |||
|---|---|---|---|
|
|||