ЧТМ:Расширения/BatchTools/1.45/0.4
(перенаправлено с «ЧТМ:Расширения/BatchTools/0.4»)
## [0.4.0] — 2026-05-24
### Добавлено
- **Новый инструмент «Массовая защита страниц»**: Позволяет одновременно ограничивать права на редактирование и перемещение для группы страниц.
- **Интерактивный рабочий стол (Workspace)**: Добавлен промежуточный буфер (таблица) на основе сессий, где можно гибко настраивать параметры для каждой страницы индивидуально перед отправкой запроса.
- **Интеграция с базой данных**: При добавлении страниц в таблицу автоматически считываются их текущие ограничения и сроки защиты с помощью `RestrictionStore`.
- **Быстрые настройки**: Добавлена панель для массовой синхронизации параметров (уровней защиты, сроков действия и флага каскадной защиты) для выбранных строк с помощью встроенного JavaScript-сценария.
- Поддержка **каскадной защиты** (автоматической защиты всех шаблонов и файлов, включённых в страницу).
- Новый базовый класс `BatchWorkspaceBase` для упрощения создания других инструментов, работающих через сессионный буфер таблиц.
- Новое системное право доступа `protectbatch`.
### Изменено
- Оптимизирован метод `SpecialBatchTools::printSessionResult()`: формирование ключей успешного выполнения и ошибок переведено на динамический шаблон `batchtools-mass{$action}-...`. Это избавляет от необходимости писать новые конструкции ветвления при добавлении будущих инструментов.
BatchTools/
├── extension.json
├── i18n/
│ ├── BatchTools.alias.php
│ ├── en.json
│ └── ru.json
└── includes/
├── SpecialBatchTools.php
└── Handlers/
├── BatchToolHandler.php
├── BatchWorkspaceBase.php
├── MassDeleteHandler.php
├── MassProtectHandler.php
└── MassUndeleteHandler.php
extension.json
[править код]{
"name": "BatchTools",
"version": "0.4",
"author": "Diman Russkov",
"descriptionmsg": "batchtools-desc",
"type": "specialpage",
"manifest_version": 2,
"requires": {
"MediaWiki": ">= 1.45.0"
},
"AvailableRights": [
"deletebatch",
"undeletebatch",
"protectbatch"
],
"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-error-cannot-read": "You do not have permission to read this page.",
"batchtools-error-cannot-delete": "You do not have permission to delete this page.",
"batchtools-error-cannot-restore": "You do not have permission to restore this page.",
"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.",
"right-protectbatch": "Mass protect pages",
"action-protectbatch": "mass protect pages",
"batchtools-tab-protect": "Mass Protect",
"batchtools-error-cannot-protect": "You do not have permission to protect this page.",
"batchtools-protect-level-all": "All (Default)",
"batchtools-protect-expiry-infinite": "Infinite",
"batchtools-protect-add-pages": "Add pages to table",
"batchtools-protect-placeholder": "List of pages (one per line)...",
"batchtools-protect-btn-add": "Add to table",
"batchtools-protect-settings-title": "Quick Settings",
"batchtools-protect-btn-sync": "Apply settings to table",
"batchtools-protect-th-page": "Page",
"batchtools-protect-th-edit": "Edit",
"batchtools-protect-th-move": "Move",
"batchtools-protect-th-expiry": "Expiry",
"batchtools-protect-th-action": "Action",
"batchtools-protect-btn-del": "Remove",
"batchtools-protect-btn-del-checked": "Remove selected",
"batchtools-protect-btn-clear-all": "Clear table",
"batchtools-protect-btn-execute": "Protect pages",
"batchtools-protect-empty-table": "The workspace is empty. Add pages above.",
"batchtools-massprotect-success": "Successfully protected $1 pages.",
"batchtools-massprotect-errors": "Failed to protect the following pages:",
"batchtools-protect-th-cascade": "Cascade",
"batchtools-protect-cascade-label": "Cascade protection (protects templates/images)",
"batchtools-protect-cascade-on": "Enable cascading",
"batchtools-protect-cascade-off": "Disable cascading"
}
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-error-cannot-read": "У вас нет прав для чтения этой страницы.",
"batchtools-error-cannot-delete": "У вас нет прав для удаления этой страницы.",
"batchtools-error-cannot-restore": "У вас нет прав для восстановления этой страницы.",
"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|страницы|страниц|страниц}} за один раз.",
"right-protectbatch": "Массовая защита страниц",
"action-protectbatch": "массово защищать страницы",
"batchtools-tab-protect": "Массовая защита",
"batchtools-error-cannot-protect": "У вас нет прав для защиты этой страницы.",
"batchtools-protect-level-all": "Все (по умолчанию)",
"batchtools-protect-expiry-infinite": "Бессрочно",
"batchtools-protect-add-pages": "Добавить страницы в таблицу",
"batchtools-protect-placeholder": "Список страниц (по одной на строке)...",
"batchtools-protect-btn-add": "Добавить в таблицу",
"batchtools-protect-settings-title": "Быстрые настройки",
"batchtools-protect-btn-sync": "Применить настройки к таблице",
"batchtools-protect-th-page": "Страница",
"batchtools-protect-th-edit": "Правка",
"batchtools-protect-th-move": "Переим.",
"batchtools-protect-th-expiry": "Срок",
"batchtools-protect-th-action": "Действие",
"batchtools-protect-btn-del": "Удалить",
"batchtools-protect-btn-del-checked": "Удалить отмеченные",
"batchtools-protect-btn-clear-all": "Очистить таблицу",
"batchtools-protect-btn-execute": "Защитить страницы",
"batchtools-protect-empty-table": "Таблица пуста. Добавьте страницы в блоке выше.",
"batchtools-massprotect-success": "Успешно защищено страниц: $1.",
"batchtools-massprotect-errors": "Не удалось защитить следующие страницы:",
"batchtools-protect-th-cascade": "Каскадная",
"batchtools-protect-cascade-label": "Каскадная защита (защитить шаблоны/файлы)",
"batchtools-protect-cascade-on": "Включить каскадную",
"batchtools-protect-cascade-off": "Выключить каскадную"
}
includes
[править код]SpecialBatchTools.php
[править код]<?php
namespace MediaWiki\Extension\BatchTools;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Html\Html;
use MediaWiki\Exception\PermissionsError;
use MediaWiki\Extension\BatchTools\Handlers\MassDeleteHandler;
use MediaWiki\Extension\BatchTools\Handlers\MassUndeleteHandler;
use MediaWiki\Extension\BatchTools\Handlers\MassProtectHandler;
class SpecialBatchTools extends SpecialPage {
public function __construct() {
parent::__construct( 'BatchTools' );
}
public function isListed() {
$authority = $this->getAuthority();
return $authority->isAllowed( 'deletebatch' )
|| $authority->isAllowed( 'undeletebatch' )
|| $authority->isAllowed( 'protectbatch' );
}
public function execute( $par ) {
$authority = $this->getAuthority();
$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'
] );
// 1. ДИНАМИЧЕСКИЙ СПИСОК ДОСТУПНЫХ ВКЛАДОК (Хэндлеров)
$handlers = [];
if ( $authority->isAllowed( 'deletebatch' ) ) {
$handlers['delete'] = [
'class' => MassDeleteHandler::class,
'label' => $this->msg( 'batchtools-tab-delete' )->text()
];
}
if ( $authority->isAllowed( 'undeletebatch' ) ) {
$handlers['undelete'] = [
'class' => MassUndeleteHandler::class,
'label' => $this->msg( 'batchtools-tab-undelete' )->text()
];
}
if ( $authority->isAllowed( 'protectbatch' ) ) {
$handlers['protect'] = [
'class' => MassProtectHandler::class,
'label' => $this->msg( 'batchtools-tab-protect' )->text()
];
}
if ( empty( $handlers ) ) {
throw new PermissionsError( 'deletebatch' );
}
// 2. ОПРЕДЕЛЯЕМ АКТИВНУЮ ВКЛАДКУ
$request = $this->getRequest();
$view = $request->getVal( 'view' );
// Если вкладка не указана или у участника нет на неё прав — кидаем на первую доступную
if ( $view === null || !array_key_exists( $view, $handlers ) ) {
$view = array_key_first( $handlers );
}
// 3. ОТРИСОВКА МЕНЮ
$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 ( $handlers as $tabKey => $tabData ) {
if ( $view === $tabKey ) {
$tabLinks[] = '<b style="color: #202122; background: #eaecf0; padding: 5px 10px; border-radius: 2px;">' . htmlspecialchars( $tabData['label'] ) . '</b>';
} else {
$url = $this->getPageTitle()->getLocalURL( [ 'view' => $tabKey ] );
$tabLinks[] = Html::element( 'a', [ 'href' => $url, 'style' => 'padding: 5px 10px;' ], $tabData['label'] );
}
}
$navHtml .= implode( '<span style="color:#a2a9b1;">|</span>', $tabLinks ) . '</div>';
$out->addHTML( $navHtml );
// Вывод общих уведомлений об успехе/ошибках
$this->printSessionResult();
// 4. ЗАПУСК НУЖНОГО ХЭНДЛЕРА
$handlerClass = $handlers[$view]['class'];
/** @var Handlers\BatchToolHandler $handlerObj */
$handlerObj = new $handlerClass( $this );
$handlerObj->execute();
}
/**
* Выводит результаты выполнения массовых операций из сессии
*/
private function printSessionResult() {
$session = $this->getRequest()->getSession();
$result = $session->get( 'batchtools_result' );
if ( $result ) {
$session->remove( 'batchtools_result' );
$html = '';
$action = $result['action'] ?? 'delete';
$msgSuccess = "batchtools-mass{$action}-success";
$msgErrors = "batchtools-mass{$action}-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 ) {
$errHtml .= '<li>' . htmlspecialchars( $err ) . '</li>';
}
$errHtml .= '</ul>';
$html .= Html::errorBox( $errHtml );
}
if ( $html !== '' ) {
$this->getOutput()->addHTML( '<div style="margin-bottom: 20px;">' . $html . '</div>' );
}
}
}
}
Handlers
[править код]BatchToolHandler.php
[править код]<?php
namespace MediaWiki\Extension\BatchTools\Handlers;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Title\Title;
/**
* Базовый класс для всех массовых инструментов.
* Содержит общие помощники и определяет структуру хэндлера.
*/
abstract class BatchToolHandler {
protected SpecialPage $specialPage; // в новых версиях php это не ошибка
public function __construct( SpecialPage $specialPage ) {
$this->specialPage = $specialPage;
}
// Удобные геттеры для доступа к контексту страницы
protected function getRequest() { return $this->specialPage->getRequest(); }
protected function getOutput() { return $this->specialPage->getOutput(); }
protected function getUser() { return $this->specialPage->getUser(); }
protected function getAuthority() { return $this->specialPage->getAuthority(); }
protected function msg( $key, ...$params ) { return $this->specialPage->msg( $key, ...$params ); }
protected function getPageTitle() { return $this->specialPage->getPageTitle(); }
/**
* Главный метод, который отрисовывает и обрабатывает вкладку
*/
abstract public function execute();
/**
* Парсит текст из Textarea, удаляет пустые строки и дубли
*/
protected function parseTextareaList( $text ) {
$lines = explode( "\n", $text );
$result = [];
foreach ( $lines as $line ) {
$line = trim( $line );
if ( $line !== '' ) {
$result[] = $line;
}
}
return array_unique( $result );
}
/**
* Проверяет права доступа к конкретной странице (для действия).
*/
protected function checkPagePermissions( string $action, Title $title ): ?string {
$authority = $this->getAuthority();
// Защита от обхода Lockdown
if ( !$authority->definitelyCan( 'read', $title ) ) {
return 'batchtools-error-cannot-read';
}
if ( !$authority->definitelyCan( $action, $title ) ) {
if ( $action === 'undelete' ) {
return 'batchtools-error-cannot-restore';
}
return 'batchtools-error-cannot-' . $action;
}
return null;
}
}
BatchWorkspaceBase.php
[править код]<?php
namespace MediaWiki\Extension\BatchTools\Handlers;
use MediaWiki\SpecialPage\SpecialPage;
/**
* Базовый класс для вкладок, которым нужна таблица (промежуточный буфер).
* Автоматически сохраняет и загружает состояние из сессии.
*/
abstract class BatchWorkspaceBase extends BatchToolHandler {
protected string $workspaceKey;
public function __construct( SpecialPage $specialPage, string $workspaceKey ) {
parent::__construct( $specialPage );
$this->workspaceKey = $workspaceKey;
}
protected function getWorkspace(): array {
$session = $this->getRequest()->getSession();
$ws = $session->get( $this->workspaceKey );
if ( !is_array( $ws ) ) {
$ws = $this->getDefaultWorkspace();
$session->set( $this->workspaceKey, $ws );
}
return $ws;
}
protected function saveWorkspace( array $workspace ): void {
$this->getRequest()->getSession()->set( $this->workspaceKey, $workspace );
}
protected function clearWorkspace(): void {
$this->getRequest()->getSession()->remove( $this->workspaceKey );
}
abstract protected function getDefaultWorkspace(): array;
}
MassDeleteHandler.php
[править код]<?php
namespace MediaWiki\Extension\BatchTools\Handlers;
use MediaWiki\Html\Html;
use MediaWiki\MediaWikiServices;
use MediaWiki\Title\Title;
use MediaWiki\Status\Status;
class MassDeleteHandler extends BatchToolHandler {
private const MAX_BATCH_SIZE = 500;
public function execute() {
$out = $this->getOutput();
$request = $this->getRequest();
$user = $this->getUser();
$authority = $this->getAuthority();
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;
}
$errKey = $this->checkPagePermissions( 'delete', $title );
if ( $errKey !== null ) {
$errors[] = "$pageName — " . $this->msg( $errKey )->text();
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 );
}
}
MassProtectHandler.php
[править код]<?php
namespace MediaWiki\Extension\BatchTools\Handlers;
use MediaWiki\Html\Html;
use MediaWiki\MediaWikiServices;
use MediaWiki\Title\Title;
use MediaWiki\Status\Status;
class MassProtectHandler extends BatchWorkspaceBase {
public function __construct( \MediaWiki\SpecialPage\SpecialPage $specialPage ) {
parent::__construct( $specialPage, 'batchtools_protect_workspace' );
}
protected function getDefaultWorkspace(): array {
return [ 'pages' => [], 'reason' => '' ];
}
// Получение уровней защиты из конфига (sysop, autoconfirmed и т.д.)
private function getLevelOptions() {
$config = MediaWikiServices::getInstance()->getMainConfig();
$restrictionLevels = $config->get( 'RestrictionLevels' );
$levels = [ '' => $this->msg( 'batchtools-protect-level-all' )->text() ];
foreach ( $restrictionLevels as $lvl ) {
if ( $lvl !== '' ) {
$msg = wfMessage( "protect-level-{$lvl}" );
$levels[$lvl] = $msg->exists() ? $msg->text() : $lvl;
}
}
return $levels;
}
// Получение вариантов срока защиты
private function getExpiryOptions() {
$options = [ 'infinity' => $this->msg( 'batchtools-protect-expiry-infinite' )->text() ];
$sysMsg = wfMessage( 'protect-expiry-options' )->inContentLanguage()->text();
foreach ( explode( ',', $sysMsg ) as $opt ) {
$parts = explode( ':', $opt, 2 );
if ( count( $parts ) === 2 ) {
$options[trim( $parts[1] )] = trim( $parts[0] );
}
}
return $options;
}
public function execute() {
$out = $this->getOutput();
$request = $this->getRequest();
$user = $this->getUser();
$authority = $this->getAuthority();
if ( !$authority->isAllowed( 'protectbatch' ) ) {
$out->addHTML( Html::errorBox( $this->msg( 'batchtools-error-nopermissions' )->text() ) );
return;
}
$workspace = $this->getWorkspace();
// ==== ОБРАБОТКА POST-ЗАПРОСОВ ====
if ( $request->wasPosted() && $user->matchEditToken( $request->getVal( 'wpEditToken' ) ) ) {
if ( isset( $_POST['mass_reason'] ) ) {
$workspace['reason'] = trim( $request->getVal( 'mass_reason' ) );
}
// Обновляем параметры из таблицы перед любым действием (включая флаг каскада)
$pagesData = $request->getArray( 'pages_data', [] );
foreach ( $pagesData as $b64 => $data ) {
$pName = base64_decode( $b64 );
if ( isset( $workspace['pages'][$pName] ) ) {
$workspace['pages'][$pName]['edit'] = $data['edit'];
$workspace['pages'][$pName]['move'] = $data['move'];
$workspace['pages'][$pName]['expiry'] = $data['expiry'];
$workspace['pages'][$pName]['cascade'] = isset( $data['cascade'] ) ? 1 : 0;
}
}
// ДЕЙСТВИЯ:
if ( $request->getVal( 'action_clear_all' ) ) {
$workspace['pages'] = [];
} elseif ( $removeKey = $request->getVal( 'action_remove_key' ) ) {
$pName = base64_decode( $removeKey );
unset( $workspace['pages'][$pName] );
} elseif ( $request->getVal( 'action_remove_mass' ) ) {
$toRemove = $request->getArray( 'mass_execute_cb', [] );
foreach ( $toRemove as $b64 ) {
$pName = base64_decode( $b64 );
unset( $workspace['pages'][$pName] );
}
} elseif ( $request->getVal( 'action_add_pages' ) ) {
$newPages = $this->parseTextareaList( $request->getVal( 'new_pages_list' ) );
// Здесь мы гарантируем, что если значение пустое или не пришло, дефолтом будет 'leave'
$optEdit = $request->getVal( 'mass_p_edit', 'leave' );
$optMove = $request->getVal( 'mass_p_move', 'leave' );
$optExpiry = $request->getVal( 'mass_p_expiry', 'leave' );
$optCascade = $request->getVal( 'mass_p_cascade', 'leave' );
if ( $optEdit === '' ) $optEdit = 'leave';
if ( $optMove === '' ) $optMove = 'leave';
if ( $optExpiry === '' ) $optExpiry = 'leave';
if ( $optCascade === '' ) $optCascade = 'leave';
$restrictionStore = MediaWikiServices::getInstance()->getRestrictionStore();
foreach ( $newPages as $pName ) {
if ( !isset( $workspace['pages'][$pName] ) ) {
$title = Title::newFromText( $pName );
$exists = $title && $title->exists();
// 1. Получаем РЕАЛЬНЫЕ текущие настройки страницы из БД (если она существует)
$curEdit = '';
$curMove = '';
$curExpiry = 'infinity';
$curCascade = 0;
if ( $exists ) {
$pageIdentity = $title->toPageIdentity();
$restrsEdit = $restrictionStore->getRestrictions( $pageIdentity, 'edit' );
$curEdit = !empty( $restrsEdit ) ? $restrsEdit[0] : '';
$restrsMove = $restrictionStore->getRestrictions( $pageIdentity, 'move' );
$curMove = !empty( $restrsMove ) ? $restrsMove[0] : '';
// Срок берем по edit (или move, если edit пустой)
$exp = $restrictionStore->getRestrictionExpiry( $pageIdentity, 'edit' );
if ( !$exp ) {
$exp = $restrictionStore->getRestrictionExpiry( $pageIdentity, 'move' );
}
$curExpiry = ( !$exp || $exp === 'infinity' ) ? 'infinity' : $exp;
$curCascade = $restrictionStore->areRestrictionsCascading( $pageIdentity ) ? 1 : 0;
}
// 2. Накладываем "Быстрые настройки" из формы добавления, если там выбрано НЕ "leave"
$finalEdit = ( $optEdit === 'leave' ) ? $curEdit : $optEdit;
$finalMove = ( $optMove === 'leave' ) ? $curMove : $optMove;
$finalExpiry = ( $optExpiry === 'leave' ) ? $curExpiry : $optExpiry;
if ( $optCascade === 'leave' ) {
$finalCascade = $curCascade;
} else {
$finalCascade = (int)$optCascade;
}
$workspace['pages'][$pName] = [
'exists' => $exists,
'edit' => $finalEdit,
'move' => $finalMove,
'expiry' => $finalExpiry,
'cascade' => $finalCascade
];
}
}
}
$this->saveWorkspace( $workspace );
// ==== ФИНАЛЬНОЕ ВЫПОЛНЕНИЕ БАТЧ-ОПЕРАЦИИ ====
if ( $request->getVal( 'action_execute' ) ) {
$selectedCb = $request->getArray( 'mass_execute_cb', [] );
$selectedLookup = array_flip( $selectedCb );
$successCount = 0; $errors = [];
$wikiPageFactory = MediaWikiServices::getInstance()->getWikiPageFactory();
foreach ( $workspace['pages'] as $pName => $pData ) {
$b64 = base64_encode( $pName );
if ( !isset( $selectedLookup[$b64] ) ) continue;
if ( !$pData['exists'] ) {
$errors[] = "$pName — страница не существует.";
continue;
}
$title = Title::newFromText( $pName );
if ( !$title || !$title->exists() ) {
$errors[] = "$pName — недопустимое имя или страница удалена.";
continue;
}
$errKey = $this->checkPagePermissions( 'protect', $title );
if ( $errKey !== null ) {
$errors[] = "$pName — " . $this->msg( $errKey )->text();
continue;
}
$wikiPage = $wikiPageFactory->newFromTitle( $title );
$limits = []; $expiries = [];
$limits['edit'] = $pData['edit']; $expiries['edit'] = $pData['expiry'];
$limits['move'] = $pData['move']; $expiries['move'] = $pData['expiry'];
// Передаем каскадную защиту через переменную
$cascade = (bool)($pData['cascade'] ?? false);
$status = $wikiPage->doUpdateRestrictions( $limits, $expiries, $cascade, $workspace['reason'], $user );
if ( $status->isOK() ) {
$successCount++;
} else {
$statusObj = Status::wrap( $status );
$errors[] = "$pName — " . $statusObj->getMessage()->text();
}
}
$request->getSession()->set( 'batchtools_result', [
'action' => 'protect', 'success_count' => $successCount, 'errors' => $errors
] );
$this->clearWorkspace();
$out->redirect( $this->getPageTitle()->getFullURL( [ 'view' => 'protect' ] ) );
return;
}
}
// ==== РЕНДЕРИНГ ИНТЕРФЕЙСА ====
$formAction = $this->getPageTitle()->getLocalURL( [ 'view' => 'protect' ] );
$html = '<form method="post" action="' . htmlspecialchars( $formAction ) . '">';
$html .= Html::hidden( 'wpEditToken', $user->getEditToken() );
// 1. БЛОК ДОБАВЛЕНИЯ СТРАНИЦ
$levels = $this->getLevelOptions();
$expiries = $this->getExpiryOptions();
// Настройки "Оставить текущие" для меню импорта сверху
$syncOptions = array_merge( [ 'leave' => '-- Оставить текущие --' ], $levels );
$syncExpiries = array_merge( [ 'leave' => '-- Оставить текущий --' ], $expiries );
$syncCascade = [
'leave' => '-- Оставить текущую --',
'1' => $this->msg( 'batchtools-protect-cascade-on' )->text(),
'0' => $this->msg( 'batchtools-protect-cascade-off' )->text(),
];
$pageInput = new \OOUI\MultilineTextInputWidget([
'name' => 'new_pages_list', 'rows' => 4, 'placeholder' => $this->msg( 'batchtools-protect-placeholder' )->text(), 'infusable' => true
]);
$addBtn = new \OOUI\ButtonInputWidget([
'type' => 'submit', 'name' => 'action_add_pages', 'value' => '1',
'label' => $this->msg( 'batchtools-protect-btn-add' )->text(), 'flags' => [ 'progressive' ], 'infusable' => true
]);
// === ЗДЕСЬ МЫ ЯВНО УКАЗЫВАЕМ 'value' => 'leave' ===
$editDropdown = new \OOUI\DropdownInputWidget([ 'name' => 'mass_p_edit', 'options' => $this->formatOOUIOptions( $syncOptions ), 'value' => 'leave', 'infusable' => true ]);
$moveDropdown = new \OOUI\DropdownInputWidget([ 'name' => 'mass_p_move', 'options' => $this->formatOOUIOptions( $syncOptions ), 'value' => 'leave', 'infusable' => true ]);
$expiryDropdown = new \OOUI\DropdownInputWidget([ 'name' => 'mass_p_expiry', 'options' => $this->formatOOUIOptions( $syncExpiries ), 'value' => 'leave', 'infusable' => true ]);
$cascadeDropdown = new \OOUI\DropdownInputWidget([ 'name' => 'mass_p_cascade', 'options' => $this->formatOOUIOptions( $syncCascade ), 'value' => 'leave', 'infusable' => true ]);
$syncBtnHtml = '<button type="button" id="bt-protect-sync" class="mw-ui-button mw-ui-progressive mw-ui-quiet" style="width:100%; border:1px solid #36c;">' . $this->msg( 'batchtools-protect-btn-sync' )->escaped() . '</button>';
$html .= '<div style="background:#eaecf0; padding:15px; border:1px solid #c8ccd1; margin-bottom:30px; border-radius: 2px;">';
$html .= '<h4 style="margin-top:0;">' . $this->msg( 'batchtools-protect-add-pages' )->text() . '</h4>';
$html .= '<div style="display:flex; gap: 20px; flex-wrap: wrap;">';
$html .= '<div style="flex: 2; min-width: 300px;">';
$html .= $pageInput->toString();
$html .= '<div style="margin-top:10px;">' . $addBtn->toString() . '</div>';
$html .= '</div>';
$html .= '<div style="flex: 1; min-width: 250px; display:flex; flex-direction:column; gap: 10px;">';
$html .= '<div><b>' . $this->msg( 'batchtools-protect-th-edit' )->text() . '</b><br>' . $editDropdown->toString() . '</div>';
$html .= '<div><b>' . $this->msg( 'batchtools-protect-th-move' )->text() . '</b><br>' . $moveDropdown->toString() . '</div>';
$html .= '<div><b>' . $this->msg( 'batchtools-protect-th-expiry' )->text() . '</b><br>' . $expiryDropdown->toString() . '</div>';
$html .= '<div><b>' . $this->msg( 'batchtools-protect-th-cascade' )->text() . '</b><br>' . $cascadeDropdown->toString() . '</div>';
$html .= '<div style="margin-top: auto;">' . $syncBtnHtml . '</div>';
$html .= '</div></div></div>';
// 2. БЛОК ВЫПОЛНЕНИЯ И ТАБЛИЦА (если есть страницы)
if ( count( $workspace['pages'] ) > 0 ) {
$reasonInput = new \OOUI\TextInputWidget([ 'name' => 'mass_reason', 'value' => $workspace['reason'], 'infusable' => true ]);
$execBtn = new \OOUI\ButtonInputWidget([ 'type' => 'submit', 'name' => 'action_execute', 'value' => '1', 'label' => $this->msg( 'batchtools-protect-btn-execute' )->text(), 'flags' => ['primary', 'progressive'], 'infusable' => true ]);
$html .= '<div style="background:#f8f9fa; padding:15px; border:1px solid #c8ccd1; margin-bottom:20px; border-left: 4px solid #36c; display: flex; align-items: center; gap: 15px;">';
$html .= '<div><b>' . $this->msg( 'batchtools-reason' )->text() . '</b></div>';
$html .= '<div style="flex-grow:1;">' . $reasonInput->toString() . '</div>';
$html .= '<div>' . $execBtn->toString() . '</div>';
$html .= '</div>';
$delCheckedBtn = new \OOUI\ButtonInputWidget([ 'type' => 'submit', 'name' => 'action_remove_mass', 'value' => '1', 'label' => $this->msg( 'batchtools-protect-btn-del-checked' )->text(), 'flags' => ['destructive'], 'framed' => false, 'infusable' => true ]);
$clearAllBtn = new \OOUI\ButtonInputWidget([ 'type' => 'submit', 'name' => 'action_clear_all', 'value' => '1', 'label' => $this->msg( 'batchtools-protect-btn-clear-all' )->text(), 'flags' => ['destructive'], 'framed' => false, 'infusable' => true ]);
$html .= '<div style="margin-bottom: 10px;">' . $delCheckedBtn->toString() . ' ' . $clearAllBtn->toString() . '</div>';
$html .= '<table class="wikitable" style="width: 100%;">';
$html .= '<tr><th style="width: 30px; text-align:center;"><input type="checkbox" checked onclick="document.querySelectorAll(\'.bt-cb\').forEach(cb => cb.checked = this.checked);"></th>';
$html .= '<th>' . $this->msg( 'batchtools-protect-th-page' )->text() . '</th><th>' . $this->msg( 'batchtools-protect-th-edit' )->text() . '</th><th>' . $this->msg( 'batchtools-protect-th-move' )->text() . '</th><th>' . $this->msg( 'batchtools-protect-th-expiry' )->text() . '</th><th>' . $this->msg( 'batchtools-protect-th-cascade' )->text() . '</th><th>' . $this->msg( 'batchtools-protect-th-action' )->text() . '</th></tr>';
foreach ( $workspace['pages'] as $pName => $pData ) {
$b64 = base64_encode( $pName );
$html .= '<tr>';
$html .= '<td style="text-align:center;"><input type="checkbox" name="mass_execute_cb[]" value="'.$b64.'" class="bt-cb" checked style="cursor:pointer;"></td>';
if ( $pData['exists'] ) {
$html .= '<td><b>' . htmlspecialchars( $pName ) . '</b></td>';
$html .= '<td>' . $this->renderSelect( "pages_data[{$b64}][edit]", $levels, $pData['edit'] ) . '</td>';
$html .= '<td>' . $this->renderSelect( "pages_data[{$b64}][move]", $levels, $pData['move'] ) . '</td>';
$html .= '<td>' . $this->renderSelect( "pages_data[{$b64}][expiry]", $expiries, $pData['expiry'] ) . '</td>';
// Чекбокс каскадной защиты для строки таблицы
$cascadeChecked = !empty( $pData['cascade'] ) ? ' checked' : '';
$html .= '<td style="text-align:center;"><input type="checkbox" name="pages_data[' . $b64 . '][cascade]" value="1" class="bt-cascade-cb"' . $cascadeChecked . ' style="cursor:pointer;"></td>';
} else {
$html .= '<td><strike>' . htmlspecialchars( $pName ) . '</strike> <span style="color:#d33; font-size:0.9em;">(не существует)</span></td>';
$html .= '<td><select class="mw-ui-input mw-ui-small" disabled><option>—</option></select></td>';
$html .= '<td><select class="mw-ui-input mw-ui-small" disabled><option>—</option></select></td>';
$html .= '<td><select class="mw-ui-input mw-ui-small" disabled><option>—</option></select></td>';
$html .= '<td style="text-align:center;"><input type="checkbox" disabled></td>';
}
$delRowBtn = new \OOUI\ButtonInputWidget([ 'type' => 'submit', 'name' => 'action_remove_key', 'value' => $b64, 'label' => $this->msg( 'batchtools-protect-btn-del' )->text(), 'flags' => ['destructive'], 'framed' => false, 'infusable' => true ]);
$html .= '<td>' . $delRowBtn->toString() . '</td></tr>';
}
$html .= '</table>';
} else {
$html .= '<div class="warningbox">' . $this->msg( 'batchtools-protect-empty-table' )->text() . '</div>';
}
$html .= '</form>';
// JS для синхронизации настроек "Применить ко всем"
$html .= '<script>
document.getElementById("bt-protect-sync")?.addEventListener("click", function(e) {
e.preventDefault();
var elEdit = document.querySelector(\'[name="mass_p_edit"]\');
var elMove = document.querySelector(\'[name="mass_p_move"]\');
var elExp = document.querySelector(\'[name="mass_p_expiry"]\');
var elCascade = document.querySelector(\'[name="mass_p_cascade"]\');
var edit = elEdit ? elEdit.value : "leave";
var move = elMove ? elMove.value : "leave";
var exp = elExp ? elExp.value : "leave";
var cascade = elCascade ? elCascade.value : "leave";
document.querySelectorAll(".bt-cb:checked").forEach(function(cb) {
var tr = cb.closest("tr");
if (edit !== "leave") {
var selEdit = tr.querySelector(\'select[name$="[edit]"]\');
if (selEdit && !selEdit.disabled) selEdit.value = edit;
}
if (move !== "leave") {
var selMove = tr.querySelector(\'select[name$="[move]"]\');
if (selMove && !selMove.disabled) selMove.value = move;
}
if (exp !== "leave") {
var selExp = tr.querySelector(\'select[name$="[expiry]"]\');
if (selExp && !selExp.disabled) selExp.value = exp;
}
if (cascade !== "leave") {
var selCascade = tr.querySelector(\'.bt-cascade-cb\');
if (selCascade && !selCascade.disabled) {
selCascade.checked = (cascade === "1");
}
}
});
});
</script>';
$out->addHTML( $html );
}
private function formatOOUIOptions( array $assoc ): array {
$res = [];
foreach ( $assoc as $val => $label ) {
$res[] = [ 'data' => (string)$val, 'label' => $label ];
}
return $res;
}
private function renderSelect( $name, $options, $selected ) {
// Если установленного в БД значения (например, точного таймстампа) нет в списке стандартных опций выпадающего списка,
// то мы красиво форматируем его и динамически добавляем прямо в выпадающий список текущей строки таблицы.
if ( !isset( $options[$selected] ) && $selected !== '' ) {
if ( preg_match( '/^\d{14}$/', $selected ) ) {
global $wgLang;
$options[$selected] = $wgLang->timeanddate( $selected, true );
} else {
$options[$selected] = $selected;
}
}
$html = '<select name="' . htmlspecialchars( $name ) . '" class="mw-ui-input mw-ui-small">';
foreach ( $options as $val => $label ) {
$sel = ( (string)$val === (string)$selected ) ? ' selected' : '';
$html .= '<option value="' . htmlspecialchars( $val ) . '"' . $sel . '>' . htmlspecialchars( $label ) . '</option>';
}
$html .= '</select>';
return $html;
}
}
MassUndeleteHandler.php
[править код]<?php
namespace MediaWiki\Extension\BatchTools\Handlers;
use MediaWiki\Html\Html;
use MediaWiki\MediaWikiServices;
use MediaWiki\Title\Title;
use MediaWiki\Status\Status;
class MassUndeleteHandler extends BatchToolHandler {
private const MAX_BATCH_SIZE = 500;
public function execute() {
$out = $this->getOutput();
$request = $this->getRequest();
$user = $this->getUser();
$authority = $this->getAuthority();
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;
}
$errKey = $this->checkPagePermissions( 'undelete', $title );
if ( $errKey !== null ) {
$errors[] = "$pageName — " . $this->msg( $errKey )->text();
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 );
}
}
| BatchTools | |||
|---|---|---|---|
|
|||