45-0.6.zip СКАЧАТЬ ZIP

## [0.6.0] — 2026-05-25

### Добавлено
- **Новый инструмент «Массовая блокировка»**: Позволяет одновременно блокировать группы участников или IP-адресов.
- **Интерактивные параметры блокировки**: Каждая строка таблицы снабжена раскрывающейся панелью детальных настроек. Поддерживаются:
  * Глобальная (Sitewide) или частичная (Partial) блокировка (с ограничением редактирования конкретных страниц или ID пространств имён).
  * Тонкая настройка запретов: создание учётных записей, отправка писем, редактирование собственной страницы обсуждения, автоблокировка IP и скрытие имени пользователя.
  * Ограничения на специфические действия: загрузка файлов, переименование страниц, создание страниц и отправка благодарностей.
- **Новый инструмент «Массовая разблокировка»**: Быстрое снятие ограничений со списков участников или IP-адресов.
- Новые права доступа `blockbatch` и `unblockbatch`.

### Изменено
- **Полная унификация интерфейса (UX)**: Инструменты «Массовое удаление» и «Массовое восстановление» полностью переведены на использование интерактивного рабочего стола (`BatchWorkspaceBase`).
- Все 5 вкладок расширения теперь работают по общему стандарту: предварительный импорт списков в таблицу-буфер, валидация существования объектов в реальном времени, возможность выборочной обработки чекбоксами и очистки рабочей области.
- Унифицированы и упорядочены системные сообщения и переводы в файлах `en.json` и `ru.json`.

extension.json

править
{
    "name": "BatchTools",
    "version": "0.6",
    "author": "Diman Russkov",
    "descriptionmsg": "batchtools-desc",
    "type": "specialpage",
    "manifest_version": 2,
    "requires": {
        "MediaWiki": ">= 1.45.0"
    },
    "AvailableRights": [
        "deletebatch",
        "undeletebatch",
        "protectbatch",
        "movebatch",
        "blockbatch",
        "unblockbatch"
    ],
    "SpecialPages": {
        "BatchTools": "MediaWiki\\Extension\\BatchTools\\SpecialBatchTools"
    },
    "AutoloadNamespaces": {
        "MediaWiki\\Extension\\BatchTools\\": "includes/"
    },
    "MessagesDirs": {
        "BatchTools": [
            "i18n"
        ]
    },
    "ExtensionMessagesFiles": {
        "BatchToolsAlias": "i18n/BatchTools.alias.php"
    }
}

BatchTools.alias.php

править
<?php
/**
 * Aliases for special pages of the BatchTools extension
 */

$specialPageAliases = [];

/** English (English) */
$specialPageAliases['en'] = [
	'BatchTools' => [ 'BatchTools' ],
];

/** Russian (Русский) */
$specialPageAliases['ru'] = [
	'BatchTools' => [ 'BatchTools', 'Пакетные_инструменты' ],
];
{
    "@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",
    "right-unblockbatch": "Mass unblock users",
    "action-unblockbatch": "mass unblock users",
    "batchtools-tab-delete": "Mass Delete",
    "batchtools-tab-undelete": "Mass Restore",
    "batchtools-tab-unblock": "Mass Unblock",
    "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-reason": "Reason:",
    "batchtools-massdelete-success": "Successfully deleted $1 pages.",
    "batchtools-massdelete-errors": "Failed to delete the following pages:",
    "batchtools-delete-add-pages": "Add pages to table",
    "batchtools-delete-placeholder": "List of pages (one per line)...",
    "batchtools-delete-btn-add": "Add to table",
    "batchtools-delete-btn-execute": "Delete pages",
    "batchtools-delete-empty-table": "The workspace is empty. Add pages above.",
    "batchtools-delete-th-page": "Page",
    "batchtools-delete-th-action": "Action",
    "batchtools-massrestore-success": "Successfully restored $1 pages.",
    "batchtools-massrestore-errors": "Failed to restore the following pages:",
    "batchtools-massrestore-norevisions": "The page was not deleted or has already been restored.",
    "batchtools-undelete-add-pages": "Add pages to table",
    "batchtools-undelete-placeholder": "List of pages to restore (one per line)...",
    "batchtools-undelete-btn-add": "Add to table",
    "batchtools-undelete-btn-execute": "Restore pages",
    "batchtools-undelete-empty-table": "The workspace is empty. Add pages above.",
    "batchtools-undelete-th-page": "Page",
    "batchtools-undelete-th-action": "Action",
    "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",
    "right-movebatch": "Mass move pages",
    "action-movebatch": "mass move pages",
    "batchtools-tab-move": "Mass Move",
    "batchtools-error-cannot-move": "You do not have permission to move this page.",
    "batchtools-move-placeholder": "OldTitle|NewTitle (one per line)...",
    "batchtools-move-th-newtitle": "New Title",
    "batchtools-move-th-redirect": "Redirect",
    "batchtools-move-th-subpages": "Subpages",
    "batchtools-move-redirect-label": "Leave redirect",
    "batchtools-move-subpages-label": "Move subpages",
    "batchtools-move-btn-execute": "Move pages",
    "batchtools-massmove-success": "Successfully moved $1 pages.",
    "batchtools-massmove-errors": "Failed to move the following pages:",
    "right-blockbatch": "Mass block users",
    "action-blockbatch": "mass block users",
    "batchtools-tab-block": "Mass Block",
    "batchtools-error-cannot-block": "You do not have permission to block this user.",
    "batchtools-block-placeholder": "Username or IP (one per line)...",
    "batchtools-block-btn-add": "Add to table",
    "batchtools-block-th-user": "User / IP",
    "batchtools-block-th-type": "Type",
    "batchtools-block-th-expiry": "Expiry",
    "batchtools-block-th-action": "Action",
    "batchtools-block-type-sitewide": "Sitewide",
    "batchtools-block-type-partial": "Partial",
    "batchtools-block-btn-expand": "Options ▾",
    "batchtools-block-btn-expand-all": "Expand / Collapse all details",
    "batchtools-block-nocreate": "Prevent account creation",
    "batchtools-block-noemail": "Prevent sending email",
    "batchtools-block-notalk": "Prevent editing own talk page",
    "batchtools-block-autoblock": "Autoblock IP",
    "batchtools-block-hardblock": "Block logged-in users from this IP",
    "batchtools-block-hideuser": "Hide username (indefinite only)",
    "batchtools-block-partial-pages": "Pages (comma-separated):",
    "batchtools-massblock-success": "Successfully blocked: $1.",
    "batchtools-massblock-errors": "Failed to block the following users:",
    "batchtools-block-empty-table": "The workspace is empty. Add users above.",
    "batchtools-block-btn-execute": "Block users",
    "batchtools-block-add-users": "Add users / IPs",
    "batchtools-block-namespaces": "Namespaces (comma-separated IDs):",
    "batchtools-block-action-upload": "Upload files",
    "batchtools-block-action-move": "Move pages and files",
    "batchtools-block-action-create": "Create pages",
    "batchtools-block-action-thanks": "Send thanks",
    "batchtools-block-partial-title": "Partial block settings",
    "batchtools-unblock-add-users": "Add users / IPs to table",
    "batchtools-unblock-placeholder": "Username or IP (one per line)...",
    "batchtools-unblock-btn-add": "Add to table",
    "batchtools-unblock-btn-execute": "Unblock users",
    "batchtools-unblock-empty-table": "The workspace is empty. Add users above.",
    "batchtools-unblock-th-user": "User / IP",
    "batchtools-unblock-th-action": "Action",
    "batchtools-massunblock-success": "Successfully unblocked: $1.",
    "batchtools-massunblock-errors": "Failed to unblock the following users:",
    "batchtools-error-not-exists": "does not exist",
    "batchtools-sync-leave": "-- Leave current --",
    "batchtools-sync-yes": "Yes",
    "batchtools-sync-no": "No",
    "batchtools-error-invalid-page": "page does not exist or has an invalid name.",
    "batchtools-error-invalid-title": "invalid page name.",
    "batchtools-error-page-not-exists": "page does not exist.",
    "batchtools-error-invalid-or-deleted": "invalid name or page has been deleted.",
    "batchtools-error-invalid-new-title": "invalid new page name.",
    "batchtools-error-same-title": "old and new names are the same.",
    "batchtools-error-invalid-user": "user does not exist or IP is invalid.",
    "batchtools-error-partial-page-not-exists": "page '$1' does not exist.",
    "batchtools-error-partial-no-restrictions": "at least one restriction must be specified for a partial block.",
    "batchtools-error-exception": "Error: $1",
    "batchtools-block-restrictions": "Restrictions:",
    "batchtools-block-prevent-actions": "Prevent actions:",
    "batchtools-btn-delete": "Delete"
}
{
    "@metadata": {
        "authors": []
    },
    "batchtools-desc": "Предоставляет инструменты для массовых операций",
    "batchtools": "Массовые инструменты",
    "right-deletebatch": "Массовое удаление страниц",
    "action-deletebatch": "массово удалять страницы",
    "right-undeletebatch": "Массовое восстановление страниц",
    "action-undeletebatch": "массово восстанавливать страницы",
    "right-unblockbatch": "Массовая разблокировка",
    "action-unblockbatch": "массово разблокировать участников",
    "batchtools-tab-delete": "Массовое удаление",
    "batchtools-tab-undelete": "Массовое восстановление",
    "batchtools-tab-unblock": "Массовая разблокировка",
    "batchtools-error-nopermissions": "У вас нет прав для доступа к массовым инструментам.",
    "batchtools-error-cannot-read": "У вас нет прав для чтения этой страницы.",
    "batchtools-error-cannot-delete": "У вас нет прав для удаления этой страницы.",
    "batchtools-error-cannot-restore": "У вас нет прав для восстановления этой страницы.",
    "batchtools-reason": "Причина:",
    "batchtools-massdelete-success": "Успешно удалено страниц: $1.",
    "batchtools-massdelete-errors": "Не удалось удалить следующие страницы:",
    "batchtools-delete-add-pages": "Добавить страницы в таблицу",
    "batchtools-delete-placeholder": "Список страниц (по одной на строке)...",
    "batchtools-delete-btn-add": "Добавить в таблицу",
    "batchtools-delete-btn-execute": "Удалить страницы",
    "batchtools-delete-empty-table": "Таблица пуста. Добавьте страницы в блоке выше.",
    "batchtools-delete-th-page": "Страница",
    "batchtools-delete-th-action": "Действие",
    "batchtools-massrestore-success": "Успешно восстановлено страниц: $1.",
    "batchtools-massrestore-errors": "Не удалось восстановить следующие страницы:",
    "batchtools-massrestore-norevisions": "Страница не была удалена или уже восстановлена.",
    "batchtools-undelete-add-pages": "Добавить страницы в таблицу",
    "batchtools-undelete-placeholder": "Список страниц для восстановления (по одной на строке)...",
    "batchtools-undelete-btn-add": "Добавить в таблицу",
    "batchtools-undelete-btn-execute": "Восстановить страницы",
    "batchtools-undelete-empty-table": "Таблица пуста. Добавьте страницы в блоке выше.",
    "batchtools-undelete-th-page": "Страница",
    "batchtools-undelete-th-action": "Действие",
    "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": "Выключить каскадную",
    "right-movebatch": "Массовое переименование страниц",
    "action-movebatch": "массово переименовывать страницы",
    "batchtools-tab-move": "Массовое переименование",
    "batchtools-error-cannot-move": "У вас нет прав для переименования этой страницы.",
    "batchtools-move-placeholder": "Старое название|Новое название (по одному на строке)...",
    "batchtools-move-th-newtitle": "Новое название",
    "batchtools-move-th-redirect": "Редирект",
    "batchtools-move-th-subpages": "Подстраницы",
    "batchtools-move-redirect-label": "Оставить перенаправление",
    "batchtools-move-subpages-label": "Переименовать подстраницы",
    "batchtools-move-btn-execute": "Переименовать страницы",
    "batchtools-massmove-success": "Успешно переименовано страниц: $1.",
    "batchtools-massmove-errors": "Не удалось переименовать следующие страницы:",
    "right-blockbatch": "Массовая блокировка",
    "action-blockbatch": "массово блокировать участников",
    "batchtools-tab-block": "Массовая блокировка",
    "batchtools-error-cannot-block": "У вас нет прав для блокировки этого участника.",
    "batchtools-block-placeholder": "Участник или IP (по одному на строке)...",
    "batchtools-block-btn-add": "Добавить в таблицу",
    "batchtools-block-th-user": "Участник / IP",
    "batchtools-block-th-type": "Тип",
    "batchtools-block-th-expiry": "Срок",
    "batchtools-block-th-action": "Действия",
    "batchtools-block-type-sitewide": "Во всём проекте",
    "batchtools-block-type-partial": "Частичная",
    "batchtools-block-btn-expand": "Опции ▾",
    "batchtools-block-btn-expand-all": "Развернуть/свернуть все детали",
    "batchtools-block-nocreate": "Запретить создание учёток",
    "batchtools-block-noemail": "Запретить отправку писем",
    "batchtools-block-notalk": "Запретить правку своей СО",
    "batchtools-block-autoblock": "Автоблокировка IP",
    "batchtools-block-hardblock": "Запретить правки авторизованным (с этого IP)",
    "batchtools-block-hideuser": "Скрыть имя (только бессрочно)",
    "batchtools-block-partial-pages": "Страницы (через запятую):",
    "batchtools-massblock-success": "Успешно заблокировано: $1.",
    "batchtools-massblock-errors": "Ошибки блокировки:",
    "batchtools-block-empty-table": "Таблица пуста. Добавьте участников выше.",
    "batchtools-block-btn-execute": "Заблокировать участников",
    "batchtools-block-add-users": "Добавить участников / IP",
    "batchtools-block-namespaces": "Пространства имён (ID через запятую):",
    "batchtools-block-action-upload": "Загрузка файлов",
    "batchtools-block-action-move": "Переименование страниц и файлов",
    "batchtools-block-action-create": "Создание страниц",
    "batchtools-block-action-thanks": "Отправка благодарности",
    "batchtools-block-partial-title": "Частичная блокировка",
    "batchtools-unblock-add-users": "Добавить участников / IP в таблицу",
    "batchtools-unblock-placeholder": "Участник или IP (по одному на строке)...",
    "batchtools-unblock-btn-add": "Добавить в таблицу",
    "batchtools-unblock-btn-execute": "Разблокировать участников",
    "batchtools-unblock-empty-table": "Таблица пуста. Добавьте участников выше.",
    "batchtools-unblock-th-user": "Участник / IP",
    "batchtools-unblock-th-action": "Действие",
    "batchtools-massunblock-success": "Успешно разблокировано: $1.",
    "batchtools-massunblock-errors": "Ошибки разблокировки:",
    "batchtools-error-not-exists": "не существует",
    "batchtools-sync-leave": "-- Оставить текущие --",
    "batchtools-sync-yes": "Да",
    "batchtools-sync-no": "Нет",
    "batchtools-error-invalid-page": "страница не существует или недопустимое имя.",
    "batchtools-error-invalid-title": "недопустимое имя страницы.",
    "batchtools-error-page-not-exists": "страница не существует.",
    "batchtools-error-invalid-or-deleted": "недопустимое имя или страница удалена.",
    "batchtools-error-invalid-new-title": "недопустимое новое имя страницы.",
    "batchtools-error-same-title": "старое и новое имена совпадают.",
    "batchtools-error-invalid-user": "участник не существует или некорректный IP.",
    "batchtools-error-partial-page-not-exists": "страница «$1» не существует.",
    "batchtools-error-partial-no-restrictions": "для частичной блокировки необходимо указать хотя бы одно ограничение.",
    "batchtools-error-exception": "Ошибка: $1",
    "batchtools-block-restrictions": "Ограничения:",
    "batchtools-block-prevent-actions": "Запретить действия:",
    "batchtools-btn-delete": "Удалить"
}

</syntaxhighlight>

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;
    }
    
    /**
     * Преобразует статус ошибки в чистую текстовую строку без HTML и Wikitext.
     * Помогает избежать вывода сырого кода, если системные сообщения вики
     * переопределены сложными HTML-шаблонами оформления.
     */
    protected function formatStatusError( $status ): string {
        if ( !$status instanceof \MediaWiki\Status\Status ) {
            $status = \MediaWiki\Status\Status::wrap( $status );
        }

        $message = $status->getMessage();
        $rawText = $message->text();

        // 1. Удаляем все HTML-теги
        $cleanText = strip_tags( $rawText );

        // 2. Удаляем медиа-файлы и иконки (например, [[File:Icon.svg|20px]])
        $cleanText = preg_replace( '/\[\[(File|Image|Файл|Изображение):[^\]]+\]\]/i', '', $cleanText );

        // 3. Удаляем вики-разметку выделения жирным/курсивом
        $cleanText = str_replace( [ "'''", "''" ], '', $cleanText );

        // 4. Декодируем HTML-сущности (например, &quot; обратно в кавычки)
        $cleanText = html_entity_decode( $cleanText, ENT_QUOTES | ENT_HTML5, 'UTF-8' );

        // 5. Заменяем множественные пробелы и переносы строк на один пробел
        $cleanText = preg_replace( '/\s+/', ' ', $cleanText );

        return trim( $cleanText );
    }
}

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;
}

MassBlockHandler.php

править
<?php

namespace MediaWiki\Extension\BatchTools\Handlers;

use MediaWiki\Html\Html;
use MediaWiki\MediaWikiServices;
use MediaWiki\Title\Title;

class MassBlockHandler extends BatchWorkspaceBase {

    public function __construct( \MediaWiki\SpecialPage\SpecialPage $specialPage ) {
        parent::__construct( $specialPage, 'batchtools_block_workspace' );
    }

    protected function getDefaultWorkspace(): array {
        return [ 'users' => [], 'reason' => '' ];
    }

    private function getExpiryOptions() {
        $options = [ 'infinity' => $this->msg( 'batchtools-protect-expiry-infinite' )->text() ];
        $sysMsg = wfMessage( 'ipboptions' )->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( 'blockbatch' ) ) {
            $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' ) );
            }

            // Обновляем данные из таблицы
            $usersData = $request->getArray( 'users_data', [] );
            foreach ( $usersData as $b64 => $data ) {
                $uName = base64_decode( $b64 );
                if ( isset( $workspace['users'][$uName] ) ) {
                    $workspace['users'][$uName]['type'] = $data['type'];
                    $workspace['users'][$uName]['expiry'] = $data['expiry'];
                    $workspace['users'][$uName]['nocreate'] = isset($data['nocreate']) ? 1 : 0;
                    $workspace['users'][$uName]['noemail'] = isset($data['noemail']) ? 1 : 0;
                    $workspace['users'][$uName]['notalk'] = isset($data['notalk']) ? 1 : 0;
                    $workspace['users'][$uName]['autoblock'] = isset($data['autoblock']) ? 1 : 0;
                    $workspace['users'][$uName]['hardblock'] = isset($data['hardblock']) ? 1 : 0;
                    $workspace['users'][$uName]['hideuser'] = isset($data['hideuser']) ? 1 : 0;

                    $workspace['users'][$uName]['pages'] = trim($data['pages'] ?? '');
                    $workspace['users'][$uName]['namespaces'] = trim($data['namespaces'] ?? '');
                    $workspace['users'][$uName]['action_upload'] = isset($data['action_upload']) ? 1 : 0;
                    $workspace['users'][$uName]['action_move'] = isset($data['action_move']) ? 1 : 0;
                    $workspace['users'][$uName]['action_create'] = isset($data['action_create']) ? 1 : 0;
                    $workspace['users'][$uName]['action_thanks'] = isset($data['action_thanks']) ? 1 : 0;
                }
            }

            // ДЕЙСТВИЯ:
            if ( $request->getVal( 'action_clear_all' ) ) {
                $workspace['users'] = [];
            } elseif ( $removeKey = $request->getVal( 'action_remove_key' ) ) {
                $uName = base64_decode( $removeKey );
                unset( $workspace['users'][$uName] );
            } elseif ( $request->getVal( 'action_remove_mass' ) ) {
                $toRemove = $request->getArray( 'mass_execute_cb', [] );
                foreach ( $toRemove as $b64 ) {
                    $uName = base64_decode( $b64 );
                    unset( $workspace['users'][$uName] );
                }
            } elseif ( $request->getVal( 'action_add_users' ) ) {
                $lines = $this->parseTextareaList( $request->getVal( 'new_users_list' ) );

                $qsPagesData = $request->getVal( 'qs_pages' ) ?? '';
                $qsPages = is_array( $qsPagesData ) ? implode( ',', $qsPagesData ) : $qsPagesData;

                $qsNsData = $request->getVal( 'qs_namespaces' ) ?? '';
                $qsNs = is_array( $qsNsData ) ? implode( ',', $qsNsData ) : $qsNsData;

                foreach ( $lines as $uName ) {
                    if ( !isset( $workspace['users'][$uName] ) ) {
                        $workspace['users'][$uName] = [
                            'type' => $request->getVal( 'qs_type', 'sitewide' ),
                            'expiry' => $request->getVal( 'qs_expiry', 'infinity' ),
                            'nocreate' => $request->getCheck( 'qs_nocreate' ) ? 1 : 0,
                            'noemail' => $request->getCheck( 'qs_noemail' ) ? 1 : 0,
                            'notalk' => $request->getCheck( 'qs_notalk' ) ? 1 : 0,
                            'autoblock' => $request->getCheck( 'qs_autoblock' ) ? 1 : 0,
                            'hardblock' => $request->getCheck( 'qs_hardblock' ) ? 1 : 0,
                            'hideuser' => $request->getCheck( 'qs_hideuser' ) ? 1 : 0,
                            'pages' => trim( $qsPages ),
                            'namespaces' => trim( $qsNs ),
                            'action_upload' => $request->getCheck( 'qs_action_upload' ) ? 1 : 0,
                            'action_move' => $request->getCheck( 'qs_action_move' ) ? 1 : 0,
                            'action_create' => $request->getCheck( 'qs_action_create' ) ? 1 : 0,
                            'action_thanks' => $request->getCheck( 'qs_action_thanks' ) ? 1 : 0,
                        ];
                    }
                }
            }

            $this->saveWorkspace( $workspace );

            // ==== ВЫПОЛНЕНИЕ БЛОКИРОВОК ====
            if ( $request->getVal( 'action_execute' ) ) {
                $selectedCb = $request->getArray( 'mass_execute_cb', [] );
                $selectedLookup = array_flip( $selectedCb );

                $successCount = 0; $errors = [];

                $userFactory = MediaWikiServices::getInstance()->getUserFactory();
                $userNameUtils = MediaWikiServices::getInstance()->getUserNameUtils();
                $blockUserFactory = MediaWikiServices::getInstance()->getBlockUserFactory();

                foreach ( $workspace['users'] as $uName => $pData ) {
                    $b64 = base64_encode( $uName );
                    if ( !isset( $selectedLookup[$b64] ) ) continue;

                    // Валидация существования участника
                    $targetUser = $userFactory->newFromName( $uName );
                    $isIP = $userNameUtils->isIP( $uName );

                    if ( !$isIP && ( !$targetUser || !$targetUser->isRegistered() ) ) {
                        $errors[] = "$uName — " . $this->msg('batchtools-error-invalid-user')->text();
                        continue;
                    }

                    // Формируем ограничения частичной блокировки
                    $restrictions = [];
                    if ( $pData['type'] === 'partial' ) {
                        $pageNames = explode( ',', $pData['pages'] );
                        foreach ( $pageNames as $pName ) {
                            $pName = trim( $pName );
                            if ( $pName !== '' ) {
                                $t = Title::newFromText( $pName );
                                if ( $t && $t->exists() ) {
                                    $restrictions[] = new \MediaWiki\Block\Restriction\PageRestriction( 0, $t->getArticleID() );
                                } else {
                                    $errors[] = "$uName — " . $this->msg('batchtools-error-partial-page-not-exists', $pName)->text();
                                    continue 2; // пропускаем блокировку этого участника
                                }
                            }
                        }

                        $nsList = explode( ',', $pData['namespaces'] );
                        foreach ( $nsList as $ns ) {
                            $ns = trim( $ns );
                            if ( $ns !== '' && is_numeric( $ns ) ) {
                                $restrictions[] = new \MediaWiki\Block\Restriction\NamespaceRestriction( 0, (int)$ns );
                            }
                        }

                        if ( !empty($pData['action_upload']) ) $restrictions[] = new \MediaWiki\Block\Restriction\ActionRestriction( 0, 'upload' );
                        if ( !empty($pData['action_move']) ) $restrictions[] = new \MediaWiki\Block\Restriction\ActionRestriction( 0, 'move' );
                        if ( !empty($pData['action_create']) ) $restrictions[] = new \MediaWiki\Block\Restriction\ActionRestriction( 0, 'createpage' );
                        if ( !empty($pData['action_thanks']) ) $restrictions[] = new \MediaWiki\Block\Restriction\ActionRestriction( 0, 'thanks' );

                        if ( empty( $restrictions ) ) {
                            $errors[] = "$uName — " . $this->msg('batchtools-error-partial-no-restrictions')->text();
                            continue;
                        }
                    }

                    $blockOptions = [
                        'isCreateAccountBlocked' => (bool)$pData['nocreate'],
                        'isEmailBlocked' => (bool)$pData['noemail'],
                        'isHardBlock' => (bool)$pData['hardblock'],
                        'isAutoblocking' => (bool)$pData['autoblock'],
                        'isUserTalkEditBlocked' => (bool)$pData['notalk'],
                        'isHideUser' => (bool)$pData['hideuser'],
                    ];

                    $target = ( !$isIP && $targetUser && $targetUser->isRegistered() ) ? $targetUser : $uName;

                    try {
                        $blockUser = $blockUserFactory->newBlockUser(
                            $target,
                            $authority,
                            $pData['expiry'],
                            $workspace['reason'],
                            $blockOptions,
                            $restrictions
                        );

                        // placeBlock() автоматически валидирует, применяет и записывает всё в лог
                        $status = $blockUser->placeBlock();
                        if ( $status->isOK() ) {
                            $successCount++;
                        } else {
                            $errors[] = "$uName — " . $this->formatStatusError( $status );
                        }
                    } catch ( \Exception $e ) {
                        $errors[] = "$uName — " . $this->msg('batchtools-error-exception', $e->getMessage())->text();
                    }
                }

                $request->getSession()->set( 'batchtools_result', [
                    'action' => 'block', 'success_count' => $successCount, 'errors' => $errors
                ] );

                $this->clearWorkspace();
                $out->redirect( $this->getPageTitle()->getFullURL( [ 'view' => 'block' ] ) );
                return;
            }
        }

        // ==== РЕНДЕРИНГ ИНТЕРФЕЙСА ====
        $formAction = $this->getPageTitle()->getLocalURL( [ 'view' => 'block' ] );
        $html = '<form method="post" action="' . htmlspecialchars( $formAction ) . '">';
        $html .= Html::hidden( 'wpEditToken', $user->getEditToken() );

        $expiries = $this->getExpiryOptions();

        // 1. БЛОК ДОБАВЛЕНИЯ УЧАСТНИКОВ (QUICK SETTINGS)
        $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-block-add-users' )->text() . '</h4>';

        $html .= '<div style="display:flex; gap: 20px; flex-wrap: wrap;">';

        $html .= '<div style="flex: 1; min-width: 200px;">';
        $pageInput = new \OOUI\MultilineTextInputWidget([
            'name' => 'new_users_list', 'rows' => 8, 'placeholder' => $this->msg( 'batchtools-block-placeholder' )->text(), 'infusable' => true
        ]);
        $addBtn = new \OOUI\ButtonInputWidget([
            'type' => 'submit', 'name' => 'action_add_users', 'value' => '1',
            'label' => $this->msg( 'batchtools-block-btn-add' )->text(), 'flags' => [ 'progressive' ], 'infusable' => true
        ]);
        $html .= $pageInput->toString();
        $html .= '<div style="margin-top:10px;">' . $addBtn->toString() . '</div>';
        $html .= '</div>';

        $html .= '<div style="flex: 2; min-width: 500px; background:#fff; border:1px solid #c8ccd1; padding: 15px;">';
        $html .= '<div style="display:flex; flex-wrap:wrap; gap:15px;">';

        $html .= '<div style="flex: 1; min-width:180px;">';
        $html .= '<div><b>' . $this->msg('batchtools-block-th-type')->text() . '</b><br>' . $this->renderSelect('qs_type', ['sitewide' => $this->msg('batchtools-block-type-sitewide')->text(), 'partial' => $this->msg('batchtools-block-type-partial')->text()], 'sitewide', 'id="qs_type"') . '</div>';
        $html .= '<div style="margin-top:10px;"><b>' . $this->msg('batchtools-block-th-expiry')->text() . '</b><br>' . $this->renderSelect('qs_expiry', $expiries, 'infinity', 'id="qs_expiry"') . '</div>';

        $html .= '<div style="margin-top:15px; font-size: 0.9em; line-height: 1.8;">';
        $html .= '<b>' . $this->msg('batchtools-block-restrictions')->text() . '</b><br>';
        $html .= '<label><input type="checkbox" id="qs_nocreate" name="qs_nocreate" checked> ' . $this->msg('batchtools-block-nocreate')->text() . '</label><br>';
        $html .= '<label><input type="checkbox" id="qs_noemail" name="qs_noemail"> ' . $this->msg('batchtools-block-noemail')->text() . '</label><br>';
        $html .= '<label><input type="checkbox" id="qs_notalk" name="qs_notalk"> ' . $this->msg('batchtools-block-notalk')->text() . '</label><br>';
        $html .= '<label><input type="checkbox" id="qs_autoblock" name="qs_autoblock" checked> ' . $this->msg('batchtools-block-autoblock')->text() . '</label><br>';
        $html .= '<label><input type="checkbox" id="qs_hardblock" name="qs_hardblock"> ' . $this->msg('batchtools-block-hardblock')->text() . '</label><br>';
        $html .= '<label><input type="checkbox" id="qs_hideuser" name="qs_hideuser"> ' . $this->msg('batchtools-block-hideuser')->text() . '</label>';
        $html .= '</div></div>';

        $html .= '<div id="qs_partial_wrap" style="flex: 1; min-width:250px; font-size: 0.9em; line-height: 1.8; border-left: 1px solid #eaecf0; padding-left: 15px;">';
        $html .= '<b>' . $this->msg('batchtools-block-partial-title')->text() . '</b><br>';
        $html .= '<div style="margin-bottom: 5px;"><input type="text" id="qs_pages" name="qs_pages" class="mw-ui-input" placeholder="' . $this->msg('batchtools-block-partial-pages')->escaped() . '"></div>';
        $html .= '<div style="margin-bottom: 10px;"><input type="text" id="qs_namespaces" name="qs_namespaces" class="mw-ui-input" placeholder="' . $this->msg('batchtools-block-namespaces')->escaped() . '"></div>';
        $html .= '<b>' . $this->msg('batchtools-block-prevent-actions')->text() . '</b><br>';
        $html .= '<div style="display: flex; gap: 10px; flex-wrap: wrap;">';
        $html .= '<div><label><input type="checkbox" id="qs_action_upload" name="qs_action_upload"> ' . $this->msg('batchtools-block-action-upload')->text() . '</label><br>';
        $html .= '<label><input type="checkbox" id="qs_action_move" name="qs_action_move"> ' . $this->msg('batchtools-block-action-move')->text() . '</label></div>';
        $html .= '<div><label><input type="checkbox" id="qs_action_create" name="qs_action_create"> ' . $this->msg('batchtools-block-action-create')->text() . '</label><br>';
        $html .= '<label><input type="checkbox" id="qs_action_thanks" name="qs_action_thanks"> ' . $this->msg('batchtools-block-action-thanks')->text() . '</label></div>';
        $html .= '</div></div>';

        $html .= '</div>'; 

        $html .= '<div style="margin-top: 15px;"><button type="button" id="bt-block-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></div>';
        $html .= '</div>';

        $html .= '</div></div>';

        // 2. ТАБЛИЦА С УЧАСТНИКАМИ
        if ( count( $workspace['users'] ) > 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-block-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><div style="flex-grow:1;">' . $reasonInput->toString() . '</div><div>' . $execBtn->toString() . '</div></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; display:flex; justify-content: space-between;">';
            $html .= '<div>' . $delCheckedBtn->toString() . ' ' . $clearAllBtn->toString() . '</div>';
            $html .= '<div><button type="button" class="mw-ui-button mw-ui-quiet" onclick="document.querySelectorAll(\'.bt-detail-row\').forEach(r => r.style.display = r.style.display === \'none\' ? \'table-row\' : \'none\');">' . $this->msg('batchtools-block-btn-expand-all')->escaped() . '</button></div>';
            $html .= '</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-block-th-user' )->text() . '</th>';
            $html .= '<th>' . $this->msg( 'batchtools-block-th-type' )->text() . '</th>';
            $html .= '<th>' . $this->msg( 'batchtools-block-th-expiry' )->text() . '</th>';
            $html .= '<th style="width: 180px;">' . $this->msg( 'batchtools-block-th-action' )->text() . '</th></tr>';

            $userFactory = MediaWikiServices::getInstance()->getUserFactory();
            $userNameUtils = MediaWikiServices::getInstance()->getUserNameUtils();

            foreach ( $workspace['users'] as $uName => $pData ) {
                $b64 = base64_encode( $uName );
                $targetUser = $userFactory->newFromName( $uName );
                $isIP = $userNameUtils->isIP( $uName );
                $exists = $isIP || ($targetUser && $targetUser->isRegistered());

                $html .= '<tr class="bt-main-row">';

                if ( $exists ) {
                    $html .= '<td style="text-align:center;"><input type="checkbox" name="mass_execute_cb[]" value="'.$b64.'" class="bt-cb" checked style="cursor:pointer;"></td>';
                    $html .= '<td><b>' . htmlspecialchars( $uName ) . '</b></td>';

                    $onchange = "var w = this.closest('tr').nextElementSibling.querySelector('.bt-partial-wrap'); if(this.value==='sitewide'){ w.style.opacity='0.4'; w.style.pointerEvents='none'; } else { w.style.opacity='1'; w.style.pointerEvents='auto'; }";
                    $html .= '<td>' . $this->renderSelect("users_data[{$b64}][type]", ['sitewide' => $this->msg('batchtools-block-type-sitewide')->text(), 'partial' => $this->msg('batchtools-block-type-partial')->text()], $pData['type'], 'onchange="' . $onchange . '"') . '</td>';

                    $html .= '<td>' . $this->renderSelect("users_data[{$b64}][expiry]", $expiries, $pData['expiry']) . '</td>';

                    $delRowBtn = new \OOUI\ButtonInputWidget([ 'type' => 'submit', 'name' => 'action_remove_key', 'value' => $b64, 'icon' => 'trash', 'title' => $this->msg('batchtools-btn-delete')->text(), 'flags' => ['destructive'], 'framed' => false, 'infusable' => true ]);
                    $html .= '<td><button type="button" class="mw-ui-button mw-ui-quiet" onclick="var d = this.closest(\'tr\').nextElementSibling; d.style.display = d.style.display === \'none\' ? \'table-row\' : \'none\';">' . $this->msg('batchtools-block-btn-expand')->escaped() . '</button>' . $delRowBtn->toString() . '</td></tr>';

                    $html .= '<tr class="bt-detail-row" style="display:none; background: #fdfdfd;">';
                    $html .= '<td></td><td colspan="4">';
                    $html .= '<div style="display:flex; flex-wrap:wrap; gap: 20px; padding: 10px 0; font-size: 0.95em;">';

                    $html .= '<div style="flex: 1; min-width: 200px; line-height: 1.8;">';
                    $html .= '<label><input type="checkbox" name="users_data['.$b64.'][nocreate]" value="1"' . ($pData['nocreate'] ? ' checked' : '') . '> ' . $this->msg('batchtools-block-nocreate')->text() . '</label><br>';
                    $html .= '<label><input type="checkbox" name="users_data['.$b64.'][noemail]" value="1"' . ($pData['noemail'] ? ' checked' : '') . '> ' . $this->msg('batchtools-block-noemail')->text() . '</label><br>';
                    $html .= '<label><input type="checkbox" name="users_data['.$b64.'][notalk]" value="1"' . ($pData['notalk'] ? ' checked' : '') . '> ' . $this->msg('batchtools-block-notalk')->text() . '</label><br>';
                    $html .= '<label><input type="checkbox" name="users_data['.$b64.'][autoblock]" value="1"' . ($pData['autoblock'] ? ' checked' : '') . '> ' . $this->msg('batchtools-block-autoblock')->text() . '</label><br>';
                    $html .= '<label><input type="checkbox" name="users_data['.$b64.'][hardblock]" value="1"' . ($pData['hardblock'] ? ' checked' : '') . '> ' . $this->msg('batchtools-block-hardblock')->text() . '</label><br>';
                    $html .= '<label><input type="checkbox" name="users_data['.$b64.'][hideuser]" value="1"' . ($pData['hideuser'] ? ' checked' : '') . '> ' . $this->msg('batchtools-block-hideuser')->text() . '</label>';
                    $html .= '</div>';

                    $disabledStyle = ($pData['type'] === 'sitewide') ? ' style="opacity: 0.4; pointer-events: none; flex: 2; min-width: 350px; border-left: 2px solid #eaecf0; padding-left: 15px;"' : ' style="flex: 2; min-width: 350px; border-left: 2px solid #eaecf0; padding-left: 15px;"';

                    $html .= '<div class="bt-partial-wrap"' . $disabledStyle . '>';
                    $html .= '<b>' . $this->msg('batchtools-block-partial-title')->text() . ':</b><br>';
                    $html .= '<div style="display:flex; gap:10px; margin-bottom: 10px;">';
                    $html .= '<input type="text" name="users_data['.$b64.'][pages]" value="' . htmlspecialchars($pData['pages']) . '" class="mw-ui-input" placeholder="' . $this->msg('batchtools-block-partial-pages')->escaped() . '">';
                    $html .= '<input type="text" name="users_data['.$b64.'][namespaces]" value="' . htmlspecialchars($pData['namespaces']) . '" class="mw-ui-input" placeholder="' . $this->msg('batchtools-block-namespaces')->escaped() . '">';
                    $html .= '</div>';

                    $html .= '<div style="display:flex; gap: 15px; font-size: 0.9em; flex-wrap: wrap;">';
                    $html .= '<div><label><input type="checkbox" name="users_data['.$b64.'][action_upload]" value="1"' . ($pData['action_upload'] ? ' checked' : '') . '> ' . $this->msg('batchtools-block-action-upload')->text() . '</label><br>';
                    $html .= '<label><input type="checkbox" name="users_data['.$b64.'][action_move]" value="1"' . ($pData['action_move'] ? ' checked' : '') . '> ' . $this->msg('batchtools-block-action-move')->text() . '</label></div>';
                    $html .= '<div><label><input type="checkbox" name="users_data['.$b64.'][action_create]" value="1"' . ($pData['action_create'] ? ' checked' : '') . '> ' . $this->msg('batchtools-block-action-create')->text() . '</label><br>';
                    $html .= '<label><input type="checkbox" name="users_data['.$b64.'][action_thanks]" value="1"' . ($pData['action_thanks'] ? ' checked' : '') . '> ' . $this->msg('batchtools-block-action-thanks')->text() . '</label></div>';
                    $html .= '</div>';

                    $html .= '</div></div></td></tr>';
                } else {
                    $html .= '<td style="text-align:center;"><input type="checkbox" disabled></td>';
                    $html .= '<td><strike>' . htmlspecialchars( $uName ) . '</strike> <span style="color:#d33; font-size:0.9em;">(' . $this->msg('batchtools-error-not-exists')->escaped() . ')</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>';

                    $delRowBtn = new \OOUI\ButtonInputWidget([ 'type' => 'submit', 'name' => 'action_remove_key', 'value' => $b64, 'icon' => 'trash', 'title' => $this->msg('batchtools-btn-delete')->text(), 'flags' => ['destructive'], 'framed' => false, 'infusable' => true ]);
                    $html .= '<td>' . $delRowBtn->toString() . '</td></tr>';

                    $html .= '<tr class="bt-detail-row" style="display:none;"><td></td><td colspan="4"></td></tr>';
                }
            }
            $html .= '</table>';
        } else {
            $html .= '<div class="warningbox">' . $this->msg( 'batchtools-block-empty-table' )->text() . '</div>';
        }
        $html .= '</form>';

        $html .= '<script>
            document.addEventListener("DOMContentLoaded", function () {
                var qsType = document.getElementById("qs_type");
                if (qsType) {
                    qsType.addEventListener("change", function(e) {
                        var wrap = document.getElementById("qs_partial_wrap");
                        if(wrap) {
                            if(this.value === "sitewide") {
                                wrap.style.opacity = "0.4"; wrap.style.pointerEvents = "none";
                            } else {
                                wrap.style.opacity = "1"; wrap.style.pointerEvents = "auto";
                            }
                        }
                    });
                    qsType.dispatchEvent(new Event("change"));
                }

                var btnSync = document.getElementById("bt-block-sync");
                if (btnSync) {
                    btnSync.addEventListener("click", function(e) {
                        e.preventDefault();
                        var elType = document.getElementById("qs_type");
                        var elExp = document.getElementById("qs_expiry");

                        var vType = elType ? elType.value : "sitewide";
                        var vExp = elExp ? elExp.value : "infinity";

                        var vNocreate = document.getElementById("qs_nocreate") && document.getElementById("qs_nocreate").checked;
                        var vNoemail = document.getElementById("qs_noemail") && document.getElementById("qs_noemail").checked;
                        var vNotalk = document.getElementById("qs_notalk") && document.getElementById("qs_notalk").checked;
                        var vAutoblock = document.getElementById("qs_autoblock") && document.getElementById("qs_autoblock").checked;
                        var vHardblock = document.getElementById("qs_hardblock") && document.getElementById("qs_hardblock").checked;
                        var vHideuser = document.getElementById("qs_hideuser") && document.getElementById("qs_hideuser").checked;

                        var vUpload = document.getElementById("qs_action_upload") && document.getElementById("qs_action_upload").checked;
                        var vMove = document.getElementById("qs_action_move") && document.getElementById("qs_action_move").checked;
                        var vCreate = document.getElementById("qs_action_create") && document.getElementById("qs_action_create").checked;
                        var vThanks = document.getElementById("qs_action_thanks") && document.getElementById("qs_action_thanks").checked;

                        var vPages = document.getElementById("qs_pages") ? document.getElementById("qs_pages").value : "";
                        var vNs = document.getElementById("qs_namespaces") ? document.getElementById("qs_namespaces").value : "";

                        document.querySelectorAll(".bt-cb:checked").forEach(function(cb) {
                            var trMain = cb.closest("tr");
                            var trDetail = trMain.nextElementSibling;

                            var selType = trMain.querySelector(\'select[name$="[type]"]\');
                            if (selType && !selType.disabled) {
                                selType.value = vType;
                                selType.dispatchEvent(new Event("change"));
                            }

                            var selExp = trMain.querySelector(\'select[name$="[expiry]"]\');
                            if (selExp && !selExp.disabled) {
                                selExp.value = vExp;
                            }

                            if (trDetail) {
                                var inputs = {
                                    "[nocreate]": vNocreate,
                                    "[noemail]": vNoemail,
                                    "[notalk]": vNotalk,
                                    "[autoblock]": vAutoblock,
                                    "[hardblock]": vHardblock,
                                    "[hideuser]": vHideuser,
                                    "[action_upload]": vUpload,
                                    "[action_move]": vMove,
                                    "[action_create]": vCreate,
                                    "[action_thanks]": vThanks
                                };
                                for (var nameEnd in inputs) {
                                    var el = trDetail.querySelector(\'input[name$="\' + nameEnd + \'"]\');
                                    if (el && !el.disabled) el.checked = inputs[nameEnd];
                                }

                                if ( vPages !== "" ) {
                                    var pInput = trDetail.querySelector(\'input[name$="[pages]"]\');
                                    if (pInput && !pInput.disabled) pInput.value = vPages;
                                }
                                if ( vNs !== "" ) {
                                    var nsInput = trDetail.querySelector(\'input[name$="[namespaces]"]\');
                                    if (nsInput && !nsInput.disabled) nsInput.value = vNs;
                                }
                            }
                        });
                    });
                }
            });
        </script>';

        $out->addHTML( $html );
    }

    private function renderSelect( $name, $options, $selected, $extraAttrs = '' ) {
        if ( !isset( $options[$selected] ) && $selected !== '' ) {
            $options[$selected] = $selected;
        }
        $html = '<select name="' . htmlspecialchars( $name ) . '" class="mw-ui-input mw-ui-small" ' . $extraAttrs . '>';
        foreach ( $options as $val => $label ) {
            $sel = ( (string)$val === (string)$selected ) ? ' selected' : '';
            $html .= '<option value="' . htmlspecialchars( $val ) . '"' . $sel . '>' . htmlspecialchars( $label ) . '</option>';
        }
        $html .= '</select>';
        return $html;
    }
}

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 BatchWorkspaceBase {

    public function __construct( \MediaWiki\SpecialPage\SpecialPage $specialPage ) {
        parent::__construct( $specialPage, 'batchtools_delete_workspace' );
    }

    protected function getDefaultWorkspace(): array {
        return [ 'pages' => [], 'reason' => '' ];
    }

    public function execute() {
        $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;
        }

        $workspace = $this->getWorkspace();

        // ==== ОБРАБОТКА POST-ЗАПРОСОВ ====
        if ( $request->wasPosted() && $user->matchEditToken( $request->getVal( 'wpEditToken' ) ) ) {

            if ( isset( $_POST['mass_reason'] ) ) {
                $workspace['reason'] = trim( $request->getVal( 'mass_reason' ) );
            }

            // ДЕЙСТВИЯ:
            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' ) ) {
                $lines = $this->parseTextareaList( $request->getVal( 'new_pages_list' ) );

                foreach ( $lines as $pName ) {
                    if ( !isset( $workspace['pages'][$pName] ) ) {
                        $title = Title::newFromText( $pName );
                        $exists = $title && $title->exists();

                        $workspace['pages'][$pName] = [ 'exists' => $exists ];
                    }
                }
            }

            $this->saveWorkspace( $workspace );

            // ==== ВЫПОЛНЕНИЕ УДАЛЕНИЙ ====
            if ( $request->getVal( 'action_execute' ) ) {
                $selectedCb = $request->getArray( 'mass_execute_cb', [] );
                $selectedLookup = array_flip( $selectedCb );

                $successCount = 0; $errors = [];
                $deletePageFactory = MediaWikiServices::getInstance()->getDeletePageFactory();

                foreach ( $workspace['pages'] as $pName => $pData ) {
                    $b64 = base64_encode( $pName );
                    if ( !isset( $selectedLookup[$b64] ) ) continue;

                    if ( !$pData['exists'] ) {
                        $errors[] = "$pName — " . $this->msg( 'batchtools-error-page-not-exists' )->text();
                        continue;
                    }

                    $title = Title::newFromText( $pName );
                    if ( !$title || !$title->exists() ) {
                        $errors[] = "$pName — " . $this->msg( 'batchtools-error-invalid-or-deleted' )->text();
                        continue;
                    }

                    $errKey = $this->checkPagePermissions( 'delete', $title );
                    if ( $errKey !== null ) {
                        $errors[] = "$pName — " . $this->msg( $errKey )->text();
                        continue;
                    }

                    $deletePage = $deletePageFactory->newDeletePage( $title->toPageIdentity(), $authority );
                    $status = $deletePage->deleteIfAllowed( $workspace['reason'] );

                    if ( $status->isOK() ) {
                        $successCount++;
                    } else {
                        $errors[] = "$pName — " . $this->formatStatusError( $status );
                    }
                }

                $request->getSession()->set( 'batchtools_result', [
                    'action' => 'delete', 'success_count' => $successCount, 'errors' => $errors
                ] );

                $this->clearWorkspace();
                $out->redirect( $this->getPageTitle()->getFullURL( [ 'view' => 'delete' ] ) );
                return;
            }
        }

        // ==== РЕНДЕРИНГ ИНТЕРФЕЙСА ====
        $formAction = $this->getPageTitle()->getLocalURL( [ 'view' => 'delete' ] );
        $html = '<form method="post" action="' . htmlspecialchars( $formAction ) . '">';
        $html .= Html::hidden( 'wpEditToken', $user->getEditToken() );

        // 1. БЛОК ДОБАВЛЕНИЯ СТРАНИЦ
        $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-delete-add-pages' )->text() . '</h4>';

        $pageInput = new \OOUI\MultilineTextInputWidget([
            'name' => 'new_pages_list', 'rows' => 4, 'placeholder' => $this->msg( 'batchtools-delete-placeholder' )->text(), 'infusable' => true
        ]);
        $addBtn = new \OOUI\ButtonInputWidget([
            'type' => 'submit', 'name' => 'action_add_pages', 'value' => '1',
            'label' => $this->msg( 'batchtools-delete-btn-add' )->text(), 'flags' => [ 'progressive' ], 'infusable' => true
        ]);

        $html .= '<div style="display:flex; gap: 20px; flex-wrap: wrap;">';
        $html .= '<div style="flex: 1; min-width: 300px;">';
        $html .= $pageInput->toString();
        $html .= '<div style="margin-top:10px;">' . $addBtn->toString() . '</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-delete-btn-execute' )->text(), 'flags' => ['primary', 'destructive'], 'infusable' => true ]);

            $html .= '<div style="background:#f8f9fa; padding:15px; border:1px solid #c8ccd1; margin-bottom:20px; border-left: 4px solid #d33; display: flex; align-items: center; gap: 15px;">';
            $html .= '<div><b>' . $this->msg('batchtools-reason')->text() . '</b></div><div style="flex-grow:1;">' . $reasonInput->toString() . '</div><div>' . $execBtn->toString() . '</div></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%; max-width: 800px;">';
            $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-delete-th-page' )->text() . '</th>';
            $html .= '<th style="width: 120px;">' . $this->msg( 'batchtools-delete-th-action' )->text() . '</th></tr>';

            foreach ( $workspace['pages'] as $pName => $pData ) {
                $b64 = base64_encode( $pName );
                $html .= '<tr>';

                if ( $pData['exists'] ) {
                    $html .= '<td style="text-align:center;"><input type="checkbox" name="mass_execute_cb[]" value="'.$b64.'" class="bt-cb" checked style="cursor:pointer;"></td>';
                    $html .= '<td><b>' . htmlspecialchars( $pName ) . '</b></td>';
                } else {
                    $html .= '<td style="text-align:center;"><input type="checkbox" disabled></td>';
                    $html .= '<td><strike>' . htmlspecialchars( $pName ) . '</strike> <span style="color:#d33; font-size:0.9em;">(' . $this->msg('batchtools-error-not-exists')->escaped() . ')</span></td>';
                }

                $delRowBtn = new \OOUI\ButtonInputWidget([ 'type' => 'submit', 'name' => 'action_remove_key', 'value' => $b64, 'label' => $this->msg('batchtools-btn-delete')->text(), 'flags' => ['destructive'], 'framed' => false, 'infusable' => true ]);
                $html .= '<td>' . $delRowBtn->toString() . '</td></tr>';
            }
            $html .= '</table>';
        } else {
            $html .= '<div class="warningbox">' . $this->msg( 'batchtools-delete-empty-table' )->text() . '</div>';
        }
        $html .= '</form>';

        $out->addHTML( $html );
    }
}

MassMoveHandler.php

править
<?php

namespace MediaWiki\Extension\BatchTools\Handlers;

use MediaWiki\Html\Html;
use MediaWiki\MediaWikiServices;
use MediaWiki\Title\Title;
use MediaWiki\Status\Status;

class MassMoveHandler extends BatchWorkspaceBase {

    public function __construct( \MediaWiki\SpecialPage\SpecialPage $specialPage ) {
        parent::__construct( $specialPage, 'batchtools_move_workspace' );
    }

    protected function getDefaultWorkspace(): array {
        return [ 'pages' => [], 'reason' => '' ];
    }

    public function execute() {
        $out = $this->getOutput();
        $request = $this->getRequest();
        $user = $this->getUser();
        $authority = $this->getAuthority();

        if ( !$authority->isAllowed( 'movebatch' ) ) {
            $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]['new_title'] = trim( $data['new_title'] ?? '' );
                    $workspace['pages'][$pName]['redirect'] = isset( $data['redirect'] ) ? 1 : 0;
                    $workspace['pages'][$pName]['subpages'] = isset( $data['subpages'] ) ? 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' ) ) {
                $rawText = $request->getVal( 'new_pages_list' );
                $lines = $this->parseTextareaList( $rawText );

                $optRedirect = $request->getVal( 'mass_m_redirect', 'leave' );
                $optSubpages = $request->getVal( 'mass_m_subpages', 'leave' );

                foreach ( $lines as $line ) {
                    // Разбиваем строку по разделителю |
                    $parts = explode( '|', $line, 2 );
                    $oldName = trim( $parts[0] );
                    // Если второй части нет, подставляем старое имя
                    $newName = isset( $parts[1] ) ? trim( $parts[1] ) : $oldName;

                    if ( $oldName !== '' && !isset( $workspace['pages'][$oldName] ) ) {
                        $title = Title::newFromText( $oldName );
                        $exists = $title && $title->exists();

                        $finalRedirect = ( $optRedirect === 'leave' ) ? 1 : (int)$optRedirect;
                        $finalSubpages = ( $optSubpages === 'leave' ) ? 0 : (int)$optSubpages;

                        $workspace['pages'][$oldName] = [
                            'exists' => $exists,
                            'new_title' => $newName,
                            'redirect' => $finalRedirect,
                            'subpages' => $finalSubpages
                        ];
                    }
                }
            }

            $this->saveWorkspace( $workspace );

            // ==== ФИНАЛЬНОЕ ВЫПОЛНЕНИЕ ====
            if ( $request->getVal( 'action_execute' ) ) {
                $selectedCb = $request->getArray( 'mass_execute_cb', [] );
                $selectedLookup = array_flip( $selectedCb );

                $successCount = 0; $errors = [];
                $movePageFactory = MediaWikiServices::getInstance()->getMovePageFactory();

                foreach ( $workspace['pages'] as $oldName => $pData ) {
                    $b64 = base64_encode( $oldName );
                    if ( !isset( $selectedLookup[$b64] ) ) continue;

                    if ( !$pData['exists'] ) {
                        $errors[] = "$oldName — " . $this->msg( 'batchtools-error-page-not-exists' )->text();
                        continue;
                    }

                    $oldTitle = Title::newFromText( $oldName );
                    $newTitle = Title::newFromText( $pData['new_title'] );

                    if ( !$oldTitle || !$oldTitle->exists() ) {
                        $errors[] = "$oldName — " . $this->msg( 'batchtools-error-invalid-or-deleted' )->text();
                        continue;
                    }

                    if ( !$newTitle ) {
                        $errors[] = "$oldName — " . $this->msg( 'batchtools-error-invalid-new-title' )->text();
                        continue;
                    }

                    if ( $oldTitle->equals( $newTitle ) ) {
                        $errors[] = "$oldName — " . $this->msg( 'batchtools-error-same-title' )->text();
                        continue;
                    }

                    $errKey = $this->checkPagePermissions( 'move', $oldTitle );
                    if ( $errKey !== null ) {
                        $errors[] = "$oldName — " . $this->msg( $errKey )->text();
                        continue;
                    }

                    // API 1.45: Выполняем переименование
                    $movePage = $movePageFactory->newMovePage( $oldTitle, $newTitle );
                    $createRedirect = (bool)$pData['redirect'];

                    $status = $movePage->moveIfAllowed( $authority, $workspace['reason'], $createRedirect );

                    if ( $status->isOK() ) {
                        $successCount++;

                        // Если стоит галочка "переименовать подстраницы", пробуем переименовать их
                        if ( (bool)$pData['subpages'] ) {
                            $movePage->moveSubpagesIfAllowed( $authority, $workspace['reason'], $createRedirect );
                        }
                    } else {
                        $errors[] = "$oldName — " . $this->formatStatusError( $status );
                    }
                }

                $request->getSession()->set( 'batchtools_result', [
                    'action' => 'move', 'success_count' => $successCount, 'errors' => $errors
                ] );

                $this->clearWorkspace();
                $out->redirect( $this->getPageTitle()->getFullURL( [ 'view' => 'move' ] ) );
                return;
            }
        }

        // ==== РЕНДЕРИНГ ИНТЕРФЕЙСА ====
        $formAction = $this->getPageTitle()->getLocalURL( [ 'view' => 'move' ] );
        $html = '<form method="post" action="' . htmlspecialchars( $formAction ) . '">';
        $html .= Html::hidden( 'wpEditToken', $user->getEditToken() );

        // 1. БЛОК ДОБАВЛЕНИЯ СТРАНИЦ
        $pageInput = new \OOUI\MultilineTextInputWidget([
            'name' => 'new_pages_list', 'rows' => 4, 'placeholder' => $this->msg( 'batchtools-move-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
        ]);

        $syncOptions = [
            [ 'data' => 'leave', 'label' => $this->msg( 'batchtools-sync-leave' )->text() ],
            [ 'data' => '1', 'label' => $this->msg( 'batchtools-sync-yes' )->text() ],
            [ 'data' => '0', 'label' => $this->msg( 'batchtools-sync-no' )->text() ],
        ];

        $redirectDropdown = new \OOUI\DropdownInputWidget([ 'name' => 'mass_m_redirect', 'options' => $syncOptions, 'value' => 'leave', 'infusable' => true ]);
        $subpagesDropdown = new \OOUI\DropdownInputWidget([ 'name' => 'mass_m_subpages', 'options' => $syncOptions, 'value' => 'leave', 'infusable' => true ]);

        $syncBtnHtml = '<button type="button" id="bt-move-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-move-redirect-label' )->text() . '</b><br>' . $redirectDropdown->toString() . '</div>';
        $html .= '<div><b>' . $this->msg( 'batchtools-move-subpages-label' )->text() . '</b><br>' . $subpagesDropdown->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-move-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>';
            $html .= '<th>' . $this->msg( 'batchtools-move-th-newtitle' )->text() . '</th>';
            $html .= '<th>' . $this->msg( 'batchtools-move-th-redirect' )->text() . '</th>';
            $html .= '<th>' . $this->msg( 'batchtools-move-th-subpages' )->text() . '</th>';
            $html .= '<th>' . $this->msg( 'batchtools-protect-th-action' )->text() . '</th></tr>';

            foreach ( $workspace['pages'] as $oldName => $pData ) {
                $b64 = base64_encode( $oldName );
                $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( $oldName ) . '</b></td>';

                    // Поле для редактирования нового имени прямо в таблице
                    $html .= '<td><input type="text" name="pages_data[' . $b64 . '][new_title]" value="' . htmlspecialchars( $pData['new_title'] ) . '" class="mw-ui-input"></td>';

                    // Чекбоксы
                    $redChecked = !empty( $pData['redirect'] ) ? ' checked' : '';
                    $subChecked = !empty( $pData['subpages'] ) ? ' checked' : '';

                    $html .= '<td style="text-align:center;"><input type="checkbox" name="pages_data[' . $b64 . '][redirect]" value="1" class="bt-redirect-cb"' . $redChecked . ' style="cursor:pointer;"></td>';
                    $html .= '<td style="text-align:center;"><input type="checkbox" name="pages_data[' . $b64 . '][subpages]" value="1" class="bt-subpages-cb"' . $subChecked . ' style="cursor:pointer;"></td>';
                } else {
                    $html .= '<td><strike>' . htmlspecialchars( $oldName ) . '</strike> <span style="color:#d33; font-size:0.9em;">(' . $this->msg('batchtools-error-not-exists')->escaped() . ')</span></td>';
                    $html .= '<td><input type="text" class="mw-ui-input" disabled></td>';
                    $html .= '<td style="text-align:center;"><input type="checkbox" disabled></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>';

        $html .= '<script>
            document.addEventListener("DOMContentLoaded", function() {
                var btn = document.getElementById("bt-move-sync");
                if (btn) {
                    btn.addEventListener("click", function(e) {
                        e.preventDefault();
                        var elRed = document.querySelector(\'[name="mass_m_redirect"]\');
                        var elSub = document.querySelector(\'[name="mass_m_subpages"]\');

                        var red = elRed ? elRed.value : "leave";
                        var sub = elSub ? elSub.value : "leave";

                        document.querySelectorAll(".bt-cb:checked").forEach(function(cb) {
                            var tr = cb.closest("tr");
                            if (red !== "leave") {
                                var selRed = tr.querySelector(".bt-redirect-cb");
                                if (selRed && !selRed.disabled) selRed.checked = (red === "1");
                            }
                            if (sub !== "leave") {
                                var selSub = tr.querySelector(".bt-subpages-cb");
                                if (selSub && !selSub.disabled) selSub.checked = (sub === "1");
                            }
                        });
                    });
                }
            });
        </script>';

        $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' ) );

                $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 — " . $this->msg( 'batchtools-error-page-not-exists' )->text();
                        continue;
                    }

                    $title = Title::newFromText( $pName );
                    if ( !$title || !$title->exists() ) {
                        $errors[] = "$pName — " . $this->msg( 'batchtools-error-invalid-or-deleted' )->text();
                        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 {
                        $errors[] = "$pName — " . $this->formatStatusError( $status );
                    }
                }

                $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' => $this->msg('batchtools-sync-leave')->text() ], $levels );
        $syncExpiries = array_merge( [ 'leave' => $this->msg('batchtools-sync-leave')->text() ], $expiries );
        $syncCascade = [
            'leave' => $this->msg('batchtools-sync-leave')->text(),
            '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
        ]);

        $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;">(' . $this->msg('batchtools-error-not-exists')->escaped() . ')</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>';

        $html .= '<script>
            document.addEventListener("DOMContentLoaded", function() {
                var btn = document.getElementById("bt-protect-sync");
                if (btn) {
                    btn.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;
    }
}

MassUnblockHandler.php

править
<?php

namespace MediaWiki\Extension\BatchTools\Handlers;

use MediaWiki\Html\Html;
use MediaWiki\MediaWikiServices;

class MassUnblockHandler extends BatchWorkspaceBase {

    public function __construct( \MediaWiki\SpecialPage\SpecialPage $specialPage ) {
        parent::__construct( $specialPage, 'batchtools_unblock_workspace' );
    }

    protected function getDefaultWorkspace(): array {
        return [ 'users' => [], 'reason' => '' ];
    }

    public function execute() {
        $out = $this->getOutput();
        $request = $this->getRequest();
        $user = $this->getUser();
        $authority = $this->getAuthority();

        if ( !$authority->isAllowed( 'unblockbatch' ) ) {
            $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' ) );
            }

            // ДЕЙСТВИЯ:
            if ( $request->getVal( 'action_clear_all' ) ) {
                $workspace['users'] = [];
            } elseif ( $removeKey = $request->getVal( 'action_remove_key' ) ) {
                $uName = base64_decode( $removeKey );
                unset( $workspace['users'][$uName] );
            } elseif ( $request->getVal( 'action_remove_mass' ) ) {
                $toRemove = $request->getArray( 'mass_execute_cb', [] );
                foreach ( $toRemove as $b64 ) {
                    $uName = base64_decode( $b64 );
                    unset( $workspace['users'][$uName] );
                }
            } elseif ( $request->getVal( 'action_add_users' ) ) {
                $lines = $this->parseTextareaList( $request->getVal( 'new_users_list' ) );

                foreach ( $lines as $uName ) {
                    if ( !isset( $workspace['users'][$uName] ) ) {
                        $workspace['users'][$uName] = true;
                    }
                }
            }

            $this->saveWorkspace( $workspace );

            // ==== ВЫПОЛНЕНИЕ РАЗБЛОКИРОВОК ====
            if ( $request->getVal( 'action_execute' ) ) {
                $selectedCb = $request->getArray( 'mass_execute_cb', [] );
                $selectedLookup = array_flip( $selectedCb );

                $successCount = 0; $errors = [];

                $unblockUserFactory = MediaWikiServices::getInstance()->getUnblockUserFactory();
                $userFactory = MediaWikiServices::getInstance()->getUserFactory();
                $userNameUtils = MediaWikiServices::getInstance()->getUserNameUtils();

                foreach ( $workspace['users'] as $uName => $dummy ) {
                    $b64 = base64_encode( $uName );
                    if ( !isset( $selectedLookup[$b64] ) ) continue;

                    // Валидация существования участника
                    $targetUser = $userFactory->newFromName( $uName );
                    $isIP = $userNameUtils->isIP( $uName );

                    if ( !$isIP && ( !$targetUser || !$targetUser->isRegistered() ) ) {
                        $errors[] = "$uName — " . $this->msg('batchtools-error-invalid-user')->text();
                        continue;
                    }

                    try {
                        $unblockUser = $unblockUserFactory->newUnblockUser(
                            $uName,
                            $authority,
                            $workspace['reason']
                        );

                        $status = $unblockUser->unblock();
                        if ( $status->isOK() ) {
                            $successCount++;
                        } else {
                            $errors[] = "$uName — " . $this->formatStatusError( $status );
                        }
                    } catch ( \Exception $e ) {
                        $errors[] = "$uName — " . $this->msg('batchtools-error-exception', $e->getMessage())->text();
                    }
                }

                $request->getSession()->set( 'batchtools_result', [
                    'action' => 'unblock', 'success_count' => $successCount, 'errors' => $errors
                ] );

                $this->clearWorkspace();
                $out->redirect( $this->getPageTitle()->getFullURL( [ 'view' => 'unblock' ] ) );
                return;
            }
        }

        // ==== РЕНДЕРИНГ ИНТЕРФЕЙСА ====
        $formAction = $this->getPageTitle()->getLocalURL( [ 'view' => 'unblock' ] );
        $html = '<form method="post" action="' . htmlspecialchars( $formAction ) . '">';
        $html .= Html::hidden( 'wpEditToken', $user->getEditToken() );

        // 1. БЛОК ДОБАВЛЕНИЯ УЧАСТНИКОВ
        $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-unblock-add-users' )->text() . '</h4>';

        $pageInput = new \OOUI\MultilineTextInputWidget([
            'name' => 'new_users_list', 'rows' => 4, 'placeholder' => $this->msg( 'batchtools-unblock-placeholder' )->text(), 'infusable' => true
        ]);
        $addBtn = new \OOUI\ButtonInputWidget([
            'type' => 'submit', 'name' => 'action_add_users', 'value' => '1',
            'label' => $this->msg( 'batchtools-unblock-btn-add' )->text(), 'flags' => [ 'progressive' ], 'infusable' => true
        ]);

        $html .= '<div style="display:flex; gap: 20px; flex-wrap: wrap;">';
        $html .= '<div style="flex: 1; min-width: 300px;">';
        $html .= $pageInput->toString();
        $html .= '<div style="margin-top:10px;">' . $addBtn->toString() . '</div>';
        $html .= '</div></div></div>';

        // 2. ТАБЛИЦА С УЧАСТНИКАМИ
        if ( count( $workspace['users'] ) > 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-unblock-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><div style="flex-grow:1;">' . $reasonInput->toString() . '</div><div>' . $execBtn->toString() . '</div></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%; max-width: 800px;">';
            $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-unblock-th-user' )->text() . '</th>';
            $html .= '<th style="width: 120px;">' . $this->msg( 'batchtools-unblock-th-action' )->text() . '</th></tr>';

            $userFactory = MediaWikiServices::getInstance()->getUserFactory();
            $userNameUtils = MediaWikiServices::getInstance()->getUserNameUtils();

            foreach ( $workspace['users'] as $uName => $dummy ) {
                $b64 = base64_encode( $uName );

                $targetUser = $userFactory->newFromName( $uName );
                $isIP = $userNameUtils->isIP( $uName );
                $exists = $isIP || ($targetUser && $targetUser->isRegistered());

                $html .= '<tr>';

                if ( $exists ) {
                    $html .= '<td style="text-align:center;"><input type="checkbox" name="mass_execute_cb[]" value="'.$b64.'" class="bt-cb" checked style="cursor:pointer;"></td>';
                    $html .= '<td><b>' . htmlspecialchars( $uName ) . '</b></td>';
                } else {
                    $html .= '<td style="text-align:center;"><input type="checkbox" disabled></td>';
                    $html .= '<td><strike>' . htmlspecialchars( $uName ) . '</strike> <span style="color:#d33; font-size:0.9em;">(' . $this->msg('batchtools-error-not-exists')->escaped() . ')</span></td>';
                }

                $delRowBtn = new \OOUI\ButtonInputWidget([ 'type' => 'submit', 'name' => 'action_remove_key', 'value' => $b64, 'label' => $this->msg('batchtools-btn-delete')->text(), 'flags' => ['destructive'], 'framed' => false, 'infusable' => true ]);
                $html .= '<td>' . $delRowBtn->toString() . '</td></tr>';
            }
            $html .= '</table>';
        } else {
            $html .= '<div class="warningbox">' . $this->msg( 'batchtools-unblock-empty-table' )->text() . '</div>';
        }
        $html .= '</form>';

        $out->addHTML( $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 BatchWorkspaceBase {

    public function __construct( \MediaWiki\SpecialPage\SpecialPage $specialPage ) {
        parent::__construct( $specialPage, 'batchtools_undelete_workspace' );
    }

    protected function getDefaultWorkspace(): array {
        return [ 'pages' => [], 'reason' => '' ];
    }

    public function execute() {
        $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;
        }

        $workspace = $this->getWorkspace();

        // ==== ОБРАБОТКА POST-ЗАПРОСОВ ====
        if ( $request->wasPosted() && $user->matchEditToken( $request->getVal( 'wpEditToken' ) ) ) {

            if ( isset( $_POST['mass_reason'] ) ) {
                $workspace['reason'] = trim( $request->getVal( 'mass_reason' ) );
            }

            // ДЕЙСТВИЯ:
            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' ) ) {
                $lines = $this->parseTextareaList( $request->getVal( 'new_pages_list' ) );

                foreach ( $lines as $pName ) {
                    if ( !isset( $workspace['pages'][$pName] ) ) {
                        $title = Title::newFromText( $pName );
                        $valid = $title && $title->canExist();

                        $workspace['pages'][$pName] = [ 'valid' => $valid ];
                    }
                }
            }

            $this->saveWorkspace( $workspace );

            // ==== ВЫПОЛНЕНИЕ ВОССТАНОВЛЕНИЙ ====
            if ( $request->getVal( 'action_execute' ) ) {
                $selectedCb = $request->getArray( 'mass_execute_cb', [] );
                $selectedLookup = array_flip( $selectedCb );

                $successCount = 0; $errors = [];
                $undeletePageFactory = MediaWikiServices::getInstance()->getUndeletePageFactory();

                foreach ( $workspace['pages'] as $pName => $pData ) {
                    $b64 = base64_encode( $pName );
                    if ( !isset( $selectedLookup[$b64] ) ) continue;

                    if ( !$pData['valid'] ) {
                        $errors[] = "$pName — " . $this->msg( 'batchtools-error-invalid-title' )->text();
                        continue;
                    }

                    $title = Title::newFromText( $pName );
                    $errKey = $this->checkPagePermissions( 'undelete', $title );
                    if ( $errKey !== null ) {
                        $errors[] = "$pName — " . $this->msg( $errKey )->text();
                        continue;
                    }

                    $undeletePage = $undeletePageFactory->newUndeletePage( $title->toPageIdentity(), $authority );
                    $status = $undeletePage->undeleteIfAllowed( $workspace['reason'] );

                    if ( $status->isOK() ) {
                        $val = $status->getValue();
                        $revs = $val['revs'] ?? 0;
                        $files = $val['files'] ?? 0;

                        if ( $revs > 0 || $files > 0 ) {
                            $successCount++;
                        } else {
                            $errors[] = "$pName — " . $this->msg( 'batchtools-massrestore-norevisions' )->text();
                        }
                    } else {
                        $errors[] = "$pName — " . $this->formatStatusError( $status );
                    }
                }

                $request->getSession()->set( 'batchtools_result', [
                    'action' => 'restore', 'success_count' => $successCount, 'errors' => $errors
                ] );

                $this->clearWorkspace();
                $out->redirect( $this->getPageTitle()->getFullURL( [ 'view' => 'undelete' ] ) );
                return;
            }
        }

        // ==== РЕНДЕРИНГ ИНТЕРФЕЙСА ====
        $formAction = $this->getPageTitle()->getLocalURL( [ 'view' => 'undelete' ] );
        $html = '<form method="post" action="' . htmlspecialchars( $formAction ) . '">';
        $html .= Html::hidden( 'wpEditToken', $user->getEditToken() );

        // 1. БЛОК ДОБАВЛЕНИЯ СТРАНИЦ
        $html .= '<div style="background:#eaf3ff; padding:15px; border:1px solid #36c; margin-bottom:30px; border-radius: 2px;">';
        $html .= '<h4 style="margin-top:0;">' . $this->msg( 'batchtools-undelete-add-pages' )->text() . '</h4>';

        $pageInput = new \OOUI\MultilineTextInputWidget([
            'name' => 'new_pages_list', 'rows' => 4, 'placeholder' => $this->msg( 'batchtools-undelete-placeholder' )->text(), 'infusable' => true
        ]);
        $addBtn = new \OOUI\ButtonInputWidget([
            'type' => 'submit', 'name' => 'action_add_pages', 'value' => '1',
            'label' => $this->msg( 'batchtools-undelete-btn-add' )->text(), 'flags' => [ 'progressive' ], 'infusable' => true
        ]);

        $html .= '<div style="display:flex; gap: 20px; flex-wrap: wrap;">';
        $html .= '<div style="flex: 1; min-width: 300px;">';
        $html .= $pageInput->toString();
        $html .= '<div style="margin-top:10px;">' . $addBtn->toString() . '</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-undelete-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><div style="flex-grow:1;">' . $reasonInput->toString() . '</div><div>' . $execBtn->toString() . '</div></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%; max-width: 800px;">';
            $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-undelete-th-page' )->text() . '</th>';
            $html .= '<th style="width: 120px;">' . $this->msg( 'batchtools-undelete-th-action' )->text() . '</th></tr>';

            foreach ( $workspace['pages'] as $pName => $pData ) {
                $b64 = base64_encode( $pName );
                $html .= '<tr>';

                if ( $pData['valid'] ) {
                    $html .= '<td style="text-align:center;"><input type="checkbox" name="mass_execute_cb[]" value="'.$b64.'" class="bt-cb" checked style="cursor:pointer;"></td>';
                    $html .= '<td><b>' . htmlspecialchars( $pName ) . '</b></td>';
                } else {
                    $html .= '<td style="text-align:center;"><input type="checkbox" disabled></td>';
                    $html .= '<td><strike>' . htmlspecialchars( $pName ) . '</strike> <span style="color:#d33; font-size:0.9em;">(invalid title)</span></td>';
                }

                $delRowBtn = new \OOUI\ButtonInputWidget([ 'type' => 'submit', 'name' => 'action_remove_key', 'value' => $b64, 'label' => $this->msg('batchtools-btn-delete')->text(), 'flags' => ['destructive'], 'framed' => false, 'infusable' => true ]);
                $html .= '<td>' . $delRowBtn->toString() . '</td></tr>';
            }
            $html .= '</table>';
        } else {
            $html .= '<div class="warningbox">' . $this->msg( 'batchtools-undelete-empty-table' )->text() . '</div>';
        }
        $html .= '</form>';

        $out->addHTML( $html );
    }
}

</syntaxhighlight>

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;
use MediaWiki\Extension\BatchTools\Handlers\MassMoveHandler;
use MediaWiki\Extension\BatchTools\Handlers\MassBlockHandler;
use MediaWiki\Extension\BatchTools\Handlers\MassUnblockHandler;

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' )
            || $authority->isAllowed( 'movebatch' )
            || $authority->isAllowed( 'blockbatch' )
            || $authority->isAllowed( 'unblockbatch' );
    }

    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 ( $authority->isAllowed( 'movebatch' ) ) {
            $handlers['move'] = [
                'class' => MassMoveHandler::class,
                'label' => $this->msg( 'batchtools-tab-move' )->text()
            ];
        }
        if ( $authority->isAllowed( 'blockbatch' ) ) {
            $handlers['block'] = [
                'class' => MassBlockHandler::class,
                'label' => $this->msg( 'batchtools-tab-block' )->text()
            ];
        }
        if ( $authority->isAllowed( 'unblockbatch' ) ) {
            $handlers['unblock'] = [
                'class' => MassUnblockHandler::class,
                'label' => $this->msg( 'batchtools-tab-unblock' )->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>' );
            }
        }
    }
}
BatchTools
Mediawiki 1.45 0.9 • 0.8 • 0.7 • 0.6 • 0.5 • 0.4 • 0.3 • 0.2 • 0.1