ЧТМ:Расширения/BatchTools/1.45/0.9
Перейти к навигации
Перейти к поиску
Схема
/
├── extension.json
├── i18n/
│ ├── BatchTools.alias.php
│ ├── en.json
│ └── ru.json
└── includes/
├── SpecialBatchTools.php
└── Handlers/
├── BatchToolHandler.php
├── BatchWorkspaceBase.php
├── MassBlockHandler.php
├── MassCreateAccountsHandler.php
├── MassCreatePagesHandler.php
├── MassDeleteHandler.php
├── MassMoveHandler.php
├── MassProtectHandler.php
├── MassUnblockHandler.php
├── MassUndeleteHandler.php
└── MassUploadHandler.php
extension.json
{
"name": "BatchTools",
"version": "0.9",
"author": "Diman Russkov",
"url": "https://thirdworldcup.ru/index.php/ЧТМ:Расширения/BatchTools",
"descriptionmsg": "batchtools-desc",
"type": "specialpage",
"manifest_version": 2,
"requires": {
"MediaWiki": ">= 1.45.0"
},
"AvailableRights": [
"deletebatch",
"undeletebatch",
"protectbatch",
"movebatch",
"blockbatch",
"unblockbatch",
"createaccountbatch",
"createpagebatch",
"uploadbatch"
],
"SpecialPages": {
"BatchTools": "MediaWiki\\Extension\\BatchTools\\SpecialBatchTools"
},
"AutoloadNamespaces": {
"MediaWiki\\Extension\\BatchTools\\": "includes/"
},
"MessagesDirs": {
"BatchTools": [
"i18n"
]
},
"ExtensionMessagesFiles": {
"BatchToolsAlias": "i18n/BatchTools.alias.php"
}
}
i18n
BatchTools.alias.php
<?php
/**
* Aliases for special pages of the BatchTools extension
*/
$specialPageAliases = [];
/** English (English) */
$specialPageAliases['en'] = [
'BatchTools' => [ 'BatchTools' ],
];
/** Russian (Русский) */
$specialPageAliases['ru'] = [
'BatchTools' => [ 'BatchTools', 'Массовые_инструменты' ],
];
en.json
{
"@metadata": {
"authors": []
},
"batchtools-desc": "Provides tools for batch operations (mass delete, mass undelete, etc.)",
"batchtools": "Batch tools",
"right-deletebatch": "Mass delete pages",
"action-deletebatch": "mass delete pages",
"right-undeletebatch": "Mass undelete pages",
"action-undeletebatch": "mass undelete pages",
"right-unblockbatch": "Mass unblock users",
"action-unblockbatch": "mass unblock users",
"right-protectbatch": "Mass protect pages",
"action-protectbatch": "mass protect pages",
"right-movebatch": "Mass move pages",
"action-movebatch": "mass move pages",
"right-blockbatch": "Mass block users",
"action-blockbatch": "mass block users",
"right-createaccountbatch": "Mass create accounts",
"action-createaccountbatch": "mass create accounts",
"right-createpagebatch": "Mass create pages",
"action-createpagebatch": "mass create pages",
"batchtools-tab-delete": "Delete",
"batchtools-tab-undelete": "Restore",
"batchtools-tab-unblock": "Unblock",
"batchtools-tab-protect": "Protect",
"batchtools-tab-move": "Move",
"batchtools-tab-block": "Block",
"batchtools-tab-createaccount": "Create Accounts",
"batchtools-tab-createpage": "Create Pages",
"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-error-cannot-create": "You do not have permission to create this page.",
"batchtools-error-cannot-edit": "You do not have permission to edit 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",
"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",
"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:",
"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-createaccount-add-users": "Add users to table",
"batchtools-createaccount-placeholder": "Username|Password|Email (one per line, email optional)...",
"batchtools-createaccount-qs-password": "Set single password for all:",
"batchtools-createaccount-btn-add": "Add to table",
"batchtools-createaccount-th-user": "Username",
"batchtools-createaccount-th-password": "Password",
"batchtools-createaccount-th-email": "Email",
"batchtools-createaccount-btn-execute": "Create accounts",
"batchtools-createaccount-empty-table": "The workspace is empty. Add users above.",
"batchtools-masscreateaccount-success": "Successfully created $1 accounts.",
"batchtools-masscreateaccount-errors": "Failed to create the following accounts:",
"batchtools-createpage-add-pages": "Add pages to table",
"batchtools-createpage-placeholder": "PageTitle|Var1|Var2|Var3 (one per line)...",
"batchtools-createpage-btn-add": "Add to table",
"batchtools-createpage-th-page": "Page",
"batchtools-createpage-th-var1": "Var 1 ($1)",
"batchtools-createpage-th-var2": "Var 2 ($2)",
"batchtools-createpage-th-var3": "Var 3 ($3)",
"batchtools-createpage-btn-execute": "Create pages",
"batchtools-createpage-empty-table": "The workspace is empty. Add pages above.",
"batchtools-createpage-qs-text": "Template text (use $1, $2, $3 for variables):",
"batchtools-createpage-overwrite": "Overwrite existing pages",
"batchtools-masscreatepage-success": "Successfully created/edited $1 pages.",
"batchtools-masscreatepage-errors": "Failed to create the following pages:",
"batchtools-error-empty-text": "page content is empty.",
"batchtools-error-page-exists": "page already exists.",
"batchtools-error-already-exists": "account already exists.",
"batchtools-error-empty-password": "password is empty.",
"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-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": "invalid username.",
"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",
"right-uploadbatch": "Mass upload files",
"action-uploadbatch": "mass upload files",
"batchtools-tab-upload": "Upload",
"batchtools-upload-add-files": "Select files",
"batchtools-upload-btn-execute": "Upload files",
"batchtools-upload-empty-table": "No files selected. Add files above.",
"batchtools-upload-th-file": "File",
"batchtools-upload-th-name": "Destination filename",
"batchtools-upload-th-text": "Description / Text",
"batchtools-massupload-success": "Successfully uploaded $1 files.",
"batchtools-massupload-errors": "Failed to upload the following files:",
"batchtools-upload-qs-text": "Common description/text for all files:",
"batchtools-upload-ignorewarnings": "Ignore warnings (e.g. duplicate files)",
"batchtools-error-cannot-upload": "You do not have permission to upload files.",
"action-batchtools": "Access to batch tools"
}
ru.json
{
"@metadata": {
"authors": []
},
"batchtools-desc": "Предоставляет инструменты для массовых операций",
"batchtools": "Массовые инструменты",
"right-deletebatch": "Массовое удаление страниц",
"action-deletebatch": "массово удалять страницы",
"right-undeletebatch": "Массовое восстановление страниц",
"action-undeletebatch": "массово восстанавливать страницы",
"right-unblockbatch": "Массовая разблокировка",
"action-unblockbatch": "массово разблокировать участников",
"right-protectbatch": "Массовая защита страниц",
"action-protectbatch": "массово защищать страницы",
"right-movebatch": "Массовое переименование страниц",
"action-movebatch": "массово переименовывать страницы",
"right-blockbatch": "Массовая блокировка",
"action-blockbatch": "массово блокировать участников",
"right-createaccountbatch": "Массовое создание аккаунтов",
"action-createaccountbatch": "массово создавать учётные записи",
"right-createpagebatch": "Массовое создание страниц",
"action-createpagebatch": "массово создавать страницы",
"batchtools-tab-delete": "Удаление",
"batchtools-tab-undelete": "Восстановление",
"batchtools-tab-unblock": "Разблокировка",
"batchtools-tab-protect": "Защита",
"batchtools-tab-move": "Переименование",
"batchtools-tab-block": "Блокировка",
"batchtools-tab-createaccount": "Создание аккаунтов",
"batchtools-tab-createpage": "Создание страниц",
"batchtools-error-nopermissions": "У вас нет прав для доступа к массовым инструментам.",
"batchtools-error-cannot-read": "У вас нет прав для чтения этой страницы.",
"batchtools-error-cannot-delete": "У вас нет прав для удаления этой страницы.",
"batchtools-error-cannot-restore": "У вас нет прав для восстановления этой страницы.",
"batchtools-error-cannot-create": "У вас нет прав для создания этой страницы.",
"batchtools-error-cannot-edit": "У вас нет прав для редактирования этой страницы.",
"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": "Действие",
"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": "Выключить каскадную",
"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": "Не удалось переименовать следующие страницы:",
"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-createaccount-add-users": "Добавить участников в таблицу",
"batchtools-createaccount-placeholder": "Имя|Пароль|Email (по одному на строке, email необязателен)...",
"batchtools-createaccount-qs-password": "Установить один пароль для всех:",
"batchtools-createaccount-btn-add": "Добавить в таблицу",
"batchtools-createaccount-th-user": "Имя участника",
"batchtools-createaccount-th-password": "Пароль",
"batchtools-createaccount-th-email": "Email (необяз.)",
"batchtools-createaccount-btn-execute": "Создать аккаунты",
"batchtools-createaccount-empty-table": "Таблица пуста. Добавьте участников в блоке выше.",
"batchtools-masscreateaccount-success": "Успешно создано аккаунтов: $1.",
"batchtools-masscreateaccount-errors": "Ошибки при создании аккаунтов:",
"batchtools-createpage-add-pages": "Добавить страницы в таблицу",
"batchtools-createpage-placeholder": "Название|Переменная 1|Переменная 2|Переменная 3 (по одной на строке)...",
"batchtools-createpage-btn-add": "Добавить в таблицу",
"batchtools-createpage-th-page": "Страница",
"batchtools-createpage-th-var1": "Пер. 1 ($1)",
"batchtools-createpage-th-var2": "Пер. 2 ($2)",
"batchtools-createpage-th-var3": "Пер. 3 ($3)",
"batchtools-createpage-btn-execute": "Создать страницы",
"batchtools-createpage-empty-table": "Таблица пуста. Добавьте страницы в блоке выше.",
"batchtools-createpage-qs-text": "Общий текст (используйте $1, $2, $3):",
"batchtools-createpage-overwrite": "Перезаписывать существующие (как правка)",
"batchtools-masscreatepage-success": "Успешно создано/изменено страниц: $1.",
"batchtools-masscreatepage-errors": "Ошибки при создании страниц:",
"batchtools-error-empty-text": "текст страницы пуст.",
"batchtools-error-page-exists": "страница уже существует.",
"batchtools-error-already-exists": "учётная запись уже существует.",
"batchtools-error-empty-password": "пароль не указан.",
"batchtools-error-not-exists": "не существует",
"batchtools-sync-leave": "-- Оставить текущие --",
"batchtools-sync-yes": "Да",
"batchtools-sync-no": "Нет",
"batchtools-error-invalid-page": "страница не существует или недопустимое имя.",
"batchtools-error-invalid-title": "недопустимое имя страницы.",
"batchtools-error-invalid-or-deleted": "недопустимое имя или страница удалена.",
"batchtools-error-invalid-new-title": "недопустимое новое имя страницы.",
"batchtools-error-same-title": "старое и новое имена совпадают.",
"batchtools-error-invalid-user": "некорректное имя участника.",
"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": "Удалить",
"right-uploadbatch": "Массовая загрузка файлов",
"action-uploadbatch": "массово загружать файлы",
"batchtools-tab-upload": "Загрузка",
"batchtools-upload-add-files": "Выбрать файлы",
"batchtools-upload-btn-execute": "Загрузить файлы",
"batchtools-upload-empty-table": "Файлы не выбраны. Добавьте файлы выше.",
"batchtools-upload-th-file": "Файл",
"batchtools-upload-th-name": "Конечное имя",
"batchtools-upload-th-text": "Описание",
"batchtools-massupload-success": "Успешно загружено файлов: $1.",
"batchtools-massupload-errors": "Ошибки при загрузке файлов:",
"batchtools-upload-qs-text": "Общее описание для всех файлов:",
"batchtools-upload-ignorewarnings": "Игнорировать предупреждения (например, дубликаты)",
"batchtools-error-cannot-upload": "У вас нет прав для загрузки файлов.",
"action-batchtools": "Доступ к массовым инструментам"
}
includes
SpecialBatchTools.php
<?php
namespace MediaWiki\Extension\BatchTools;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Html\Html;
use MediaWiki\Exception\PermissionsError;
use MediaWiki\Extension\BatchTools\Handlers\MassDeleteHandler;
use MediaWiki\Extension\BatchTools\Handlers\MassUndeleteHandler;
use MediaWiki\Extension\BatchTools\Handlers\MassProtectHandler;
use MediaWiki\Extension\BatchTools\Handlers\MassMoveHandler;
use MediaWiki\Extension\BatchTools\Handlers\MassBlockHandler;
use MediaWiki\Extension\BatchTools\Handlers\MassUnblockHandler;
use MediaWiki\Extension\BatchTools\Handlers\MassCreateAccountsHandler;
use MediaWiki\Extension\BatchTools\Handlers\MassCreatePagesHandler;
use MediaWiki\Extension\BatchTools\Handlers\MassUploadHandler;
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' )
|| $authority->isAllowed( 'createaccountbatch' )
|| $authority->isAllowed( 'createpagebatch' )
|| $authority->isAllowed( 'uploadbatch' );
}
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 ( $authority->isAllowed( 'createaccountbatch' ) ) {
$handlers['createaccount'] = [
'class' => MassCreateAccountsHandler::class,
'label' => $this->msg( 'batchtools-tab-createaccount' )->text()
];
}
if ( $authority->isAllowed( 'createpagebatch' ) ) {
$handlers['createpage'] = [
'class' => MassCreatePagesHandler::class,
'label' => $this->msg( 'batchtools-tab-createpage' )->text()
];
}
if ( $authority->isAllowed( 'uploadbatch' ) ) {
$handlers['upload'] = [
'class' => MassUploadHandler::class,
'label' => $this->msg( 'batchtools-tab-upload' )->text()
];
}
if ( empty( $handlers ) ) {
// Выводим сообщение об ошибке с использованием текста из i18n
throw new PermissionsError( 'batchtools', [
[ 'batchtools-error-nopermissions' ]
] );
}
// 2. ОПРЕДЕЛЯЕМ АКТИВНУЮ ВКЛАДКУ
$request = $this->getRequest();
$view = $request->getVal( 'view' );
if ( $view === null || !array_key_exists( $view, $handlers ) ) {
$view = array_key_first( $handlers );
}
// 3. ОТРИСОВКА МЕНЮ
$navHtml = '<div style="margin-bottom: 25px; font-size: 1.1em; border-bottom: 2px solid #a2a9b1; padding-bottom: 10px; display: flex; flex-wrap: wrap; gap: 10px;">';
$tabLinks = [];
foreach ( $handlers as $tabKey => $tabData ) {
if ( $view === $tabKey ) {
$tabLinks[] = '<b style="color: #202122; background: #eaecf0; padding: 5px 10px; border-radius: 2px;">' . htmlspecialchars( $tabData['label'] ) . '</b>';
} else {
$url = $this->getPageTitle()->getLocalURL( [ 'view' => $tabKey ] );
$tabLinks[] = Html::element( 'a', [ 'href' => $url, 'style' => 'padding: 5px 10px;' ], $tabData['label'] );
}
}
$navHtml .= implode( '<span style="color:#a2a9b1;">|</span>', $tabLinks ) . '</div>';
$out->addHTML( $navHtml );
// Вывод общих уведомлений об успехе/ошибках
$this->printSessionResult();
// 4. ЗАПУСК НУЖНОГО ХЭНДЛЕРА
$handlerClass = $handlers[$view]['class'];
/** @var Handlers\BatchToolHandler $handlerObj */
$handlerObj = new $handlerClass( $this );
$handlerObj->execute();
}
private function printSessionResult() {
$session = $this->getRequest()->getSession();
$result = $session->get( 'batchtools_result' );
if ( $result ) {
$session->remove( 'batchtools_result' );
$html = '';
$action = $result['action'] ?? 'delete';
$msgSuccess = "batchtools-mass{$action}-success";
$msgErrors = "batchtools-mass{$action}-errors";
if ( !empty( $result['success_count'] ) ) {
$html .= Html::successBox( $this->msg( $msgSuccess, $result['success_count'] )->text() );
}
if ( !empty( $result['errors'] ) ) {
$errHtml = '<b>' . $this->msg( $msgErrors )->text() . '</b><ul style="margin-top: 5px; margin-bottom: 0;">';
foreach ( $result['errors'] as $err ) {
$errHtml .= '<li>' . htmlspecialchars( $err ) . '</li>';
}
$errHtml .= '</ul>';
$html .= Html::errorBox( $errHtml );
}
if ( $html !== '' ) {
$this->getOutput()->addHTML( '<div style="margin-bottom: 20px;">' . $html . '</div>' );
}
}
}
}
Handlers
BatchToolHandler.php
<?php
namespace MediaWiki\Extension\BatchTools\Handlers;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Title\Title;
/**
* Базовый класс для всех массовых инструментов.
* Содержит общие помощники и определяет структуру хэндлера.
*/
abstract class BatchToolHandler {
protected SpecialPage $specialPage;
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();
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();
if ( !$authority->definitelyCan( 'read', $title ) ) {
return 'batchtools-error-cannot-read';
}
if ( !$authority->definitelyCan( $action, $title ) ) {
if ( $action === 'undelete' ) {
return 'batchtools-error-cannot-restore';
}
if ( $action === 'createpage' ) {
return 'batchtools-error-cannot-createpage';
}
return 'batchtools-error-cannot-' . $action;
}
return null;
}
/**
* Преобразует статус ошибки в чистую текстовую строку без HTML и Wikitext.
*/
protected function formatStatusError( $status ): string {
// Защита от фатальной ошибки: в MW класс Status находится в глобальном пространстве
if ( !$status instanceof \Status && !$status instanceof \StatusValue ) {
return "Unknown error";
}
if ( !$status instanceof \Status ) {
$status = \Status::wrap( $status );
}
$message = $status->getMessage();
if ( !$message ) {
return "Unknown error details";
}
$rawText = $message->text();
$cleanText = strip_tags( $rawText );
$cleanText = preg_replace( '/\[\[(File|Image|Файл|Изображение):[^\]]+\]\]/i', '', $cleanText );
$cleanText = str_replace( [ "'''", "''" ], '', $cleanText );
$cleanText = html_entity_decode( $cleanText, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
$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;
}
}
MassCreateAccountsHandler.php
<?php
namespace MediaWiki\Extension\BatchTools\Handlers;
use MediaWiki\Html\Html;
use MediaWiki\MediaWikiServices;
class MassCreateAccountsHandler extends BatchWorkspaceBase {
public function __construct( \MediaWiki\SpecialPage\SpecialPage $specialPage ) {
parent::__construct( $specialPage, 'batchtools_createaccount_workspace' );
}
protected function getDefaultWorkspace(): array {
return [ 'users' => [] ];
}
public function execute() {
$out = $this->getOutput();
$request = $this->getRequest();
$user = $this->getUser();
$authority = $this->getAuthority();
if ( !$authority->isAllowed( 'createaccountbatch' ) ) {
$out->addHTML( Html::errorBox( $this->msg( 'batchtools-error-nopermissions' )->text() ) );
return;
}
$workspace = $this->getWorkspace();
// ==== ОБРАБОТКА POST-ЗАПРОСОВ ====
if ( $request->wasPosted() && $user->matchEditToken( $request->getVal( 'wpEditToken' ) ) ) {
// Обновляем данные из таблицы
$usersData = $request->getArray( 'users_data', [] );
foreach ( $usersData as $b64 => $data ) {
$uName = base64_decode( $b64 );
if ( isset( $workspace['users'][$uName] ) ) {
$workspace['users'][$uName]['password'] = $data['password'] ?? '';
$workspace['users'][$uName]['email'] = $data['email'] ?? '';
}
}
// ДЕЙСТВИЯ:
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 $line ) {
// Парсинг формата: Участник|Пароль|Email
$parts = explode( '|', $line );
$uName = trim( $parts[0] );
$pass = isset( $parts[1] ) ? trim( $parts[1] ) : '';
$email = isset( $parts[2] ) ? trim( $parts[2] ) : '';
if ( $uName !== '' && !isset( $workspace['users'][$uName] ) ) {
$workspace['users'][$uName] = [
'password' => $pass,
'email' => $email
];
}
}
}
$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();
$authManager = MediaWikiServices::getInstance()->getAuthManager();
foreach ( $workspace['users'] as $uName => $pData ) {
$b64 = base64_encode( $uName );
if ( !isset( $selectedLookup[$b64] ) ) continue;
$targetUser = $userFactory->newFromName( $uName );
if ( !$targetUser ) {
$errors[] = "$uName — " . $this->msg('batchtools-error-invalid-user')->text();
continue;
}
if ( $targetUser->isRegistered() ) {
$errors[] = "$uName — " . $this->msg('batchtools-error-already-exists')->text();
continue;
}
if ( empty( $pData['password'] ) ) {
$errors[] = "$uName — " . $this->msg('batchtools-error-empty-password')->text();
continue;
}
try {
// Безопасное создание через AuthManager API (исправлено имя константы)
$status = $authManager->autoCreateUser(
$targetUser,
\MediaWiki\Auth\AuthManager::AUTOCREATE_SOURCE_MAINT,
false, // не логинить текущую сессию
false // не создавать автоматический лог 'autocreate', запишем правильный 'create2' вручную
);
if ( $status->isOK() ) {
// Установка email (опционально)
if ( !empty( $pData['email'] ) ) {
$targetUser->setEmail( $pData['email'] );
$targetUser->saveSettings();
}
// Безопасная установка пароля через AuthManager API для конкретного юзера
$pwStatus = $targetUser->changeAuthenticationData( [
'username' => $targetUser->getName(),
'password' => $pData['password'],
'retype' => $pData['password'],
] );
if ( $pwStatus->isOK() ) {
// Записываем в лог 'newusers' -> 'create2' (создание администратором)
$logEntry = new \ManualLogEntry( 'newusers', 'create2' );
$logEntry->setPerformer( $user ); // Автор лога — администратор
$logEntry->setTarget( $targetUser->getUserPage() );
$logEntry->setComment( '' );
$logEntry->setParameters( [
'4::userid' => $targetUser->getId(),
] );
$logId = $logEntry->insert();
$logEntry->publish( $logId );
$successCount++;
} else {
$errors[] = "$uName — " . $this->formatStatusError( $pwStatus );
}
} 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' => 'createaccount', 'success_count' => $successCount, 'errors' => $errors
] );
$this->clearWorkspace();
$out->redirect( $this->getPageTitle()->getFullURL( [ 'view' => 'createaccount' ] ) );
return;
}
}
// ==== РЕНДЕРИНГ ИНТЕРФЕЙСА ====
$formAction = $this->getPageTitle()->getLocalURL( [ 'view' => 'createaccount' ] );
$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-createaccount-add-users' )->text() . '</h4>';
$html .= '<div style="display:flex; gap: 20px; flex-wrap: wrap;">';
$html .= '<div style="flex: 2; min-width: 300px;">';
$pageInput = new \OOUI\MultilineTextInputWidget([
'name' => 'new_users_list', 'rows' => 4, 'placeholder' => $this->msg( 'batchtools-createaccount-placeholder' )->text(), 'infusable' => true
]);
$addBtn = new \OOUI\ButtonInputWidget([
'type' => 'submit', 'name' => 'action_add_users', 'value' => '1',
'label' => $this->msg( 'batchtools-createaccount-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: 1; min-width: 250px; display:flex; flex-direction:column; gap: 10px;">';
$html .= '<div><b>' . $this->msg('batchtools-createaccount-qs-password')->text() . '</b><br><input type="text" id="qs_password" class="mw-ui-input"></div>';
$html .= '<div style="margin-top: auto;"><button type="button" id="bt-ca-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></div></div>';
// 2. ТАБЛИЦА
if ( count( $workspace['users'] ) > 0 ) {
$execBtn = new \OOUI\ButtonInputWidget([
'type' => 'submit',
'name' => 'action_execute',
'value' => '1',
'label' => $this->msg( 'batchtools-createaccount-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; justify-content: flex-end; gap: 15px;">';
$html .= '<div id="bt-ca-execute-wrapper">' . $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%;">';
$html .= '<tr><th style="width: 30px; text-align:center;"><input type="checkbox" checked onclick="document.querySelectorAll(\'.bt-cb\').forEach(cb => { cb.checked = this.checked; cb.dispatchEvent(new Event(\'change\')); });"></th>';
$html .= '<th>' . $this->msg( 'batchtools-createaccount-th-user' )->text() . '</th>';
$html .= '<th>' . $this->msg( 'batchtools-createaccount-th-password' )->text() . '</th>';
$html .= '<th>' . $this->msg( 'batchtools-createaccount-th-email' )->text() . '</th>';
$html .= '<th style="width:100px;">' . $this->msg( 'batchtools-protect-th-action' )->text() . '</th></tr>';
$userFactory = MediaWikiServices::getInstance()->getUserFactory();
foreach ( $workspace['users'] as $uName => $pData ) {
$b64 = base64_encode( $uName );
$targetUser = $userFactory->newFromName( $uName );
$isValid = true;
$errStatus = '';
if ( !$targetUser ) {
$isValid = false;
$errStatus = $this->msg('batchtools-error-invalid-user')->escaped();
} elseif ( $targetUser->isRegistered() ) {
$isValid = false;
$errStatus = $this->msg('batchtools-error-already-exists')->escaped();
}
$html .= '<tr>';
if ( $isValid ) {
$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>';
$html .= '<td><input type="text" name="users_data['.$b64.'][password]" value="' . htmlspecialchars($pData['password']) . '" class="mw-ui-input bt-pass-input"></td>';
$html .= '<td><input type="text" name="users_data['.$b64.'][email]" value="' . htmlspecialchars($pData['email']) . '" class="mw-ui-input"></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;">(' . $errStatus . ')</span></td>';
$html .= '<td><input type="text" class="mw-ui-input" disabled></td>';
$html .= '<td><input type="text" class="mw-ui-input" disabled></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-createaccount-empty-table' )->text() . '</div>';
}
$html .= '</form>';
// Обратная валидация пустых паролей
$html .= '<script>
document.addEventListener("DOMContentLoaded", function () {
function validateTable() {
var hasError = false;
document.querySelectorAll(".bt-cb").forEach(function(cb) {
if (cb.checked && !cb.disabled) {
var tr = cb.closest("tr");
var passInput = tr.querySelector(".bt-pass-input");
if (passInput) {
if (passInput.value.trim() === "") {
passInput.style.border = "2px solid #d33";
passInput.style.backgroundColor = "#fee";
hasError = true;
} else {
passInput.style.border = "";
passInput.style.backgroundColor = "";
}
}
}
});
var execWrapper = document.getElementById("bt-ca-execute-wrapper");
if (execWrapper) {
var btn = execWrapper.querySelector("button, input[type=\'submit\']");
if (btn) {
btn.disabled = hasError;
if (hasError) {
execWrapper.style.opacity = "0.5";
execWrapper.style.pointerEvents = "none";
} else {
execWrapper.style.opacity = "1";
execWrapper.style.pointerEvents = "auto";
}
}
}
}
document.querySelectorAll(".bt-pass-input").forEach(function(input) {
input.addEventListener("input", validateTable);
});
document.querySelectorAll(".bt-cb").forEach(function(cb) {
cb.addEventListener("change", validateTable);
});
var btnSync = document.getElementById("bt-ca-sync");
if (btnSync) {
btnSync.addEventListener("click", function(e) {
e.preventDefault();
var qsPass = document.getElementById("qs_password").value;
if (qsPass !== "") {
document.querySelectorAll(".bt-cb:checked").forEach(function(cb) {
var tr = cb.closest("tr");
var passInput = tr.querySelector(".bt-pass-input");
if (passInput && !passInput.disabled) {
passInput.value = qsPass;
}
});
}
validateTable();
});
}
// Начальная проверка при загрузке
validateTable();
});
</script>';
$out->addHTML( $html );
}
}
MassCreatePagesHandler.php
<?php
namespace MediaWiki\Extension\BatchTools\Handlers;
use MediaWiki\Html\Html;
use MediaWiki\MediaWikiServices;
use MediaWiki\Title\Title;
class MassCreatePagesHandler extends BatchWorkspaceBase {
public function __construct( \MediaWiki\SpecialPage\SpecialPage $specialPage ) {
parent::__construct( $specialPage, 'batchtools_createpage_workspace' );
}
protected function getDefaultWorkspace(): array {
return [ 'pages' => [], 'reason' => '', 'common_text' => '', 'overwrite' => false ];
}
public function execute() {
$out = $this->getOutput();
$request = $this->getRequest();
$user = $this->getUser();
$authority = $this->getAuthority();
if ( !$authority->isAllowed( 'createpagebatch' ) ) {
$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 ( isset( $_POST['qs_text'] ) ) {
$workspace['common_text'] = $request->getVal( 'qs_text' );
}
$workspace['overwrite'] = $request->getCheck( 'mass_overwrite' );
// Обновляем данные из таблицы
$pagesData = $request->getArray( 'pages_data', [] );
foreach ( $pagesData as $b64 => $data ) {
$pName = base64_decode( $b64 );
if ( isset( $workspace['pages'][$pName] ) ) {
$workspace['pages'][$pName]['var1'] = $data['var1'] ?? '';
$workspace['pages'][$pName]['var2'] = $data['var2'] ?? '';
$workspace['pages'][$pName]['var3'] = $data['var3'] ?? '';
$workspace['pages'][$pName]['text'] = $data['text'] ?? '';
}
}
// ДЕЙСТВИЯ:
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' ) );
$template = $workspace['common_text'];
foreach ( $lines as $line ) {
$parts = explode( '|', $line );
$pName = trim( $parts[0] );
$v1 = isset($parts[1]) ? trim( $parts[1] ) : '';
$v2 = isset($parts[2]) ? trim( $parts[2] ) : '';
$v3 = isset($parts[3]) ? trim( $parts[3] ) : '';
if ( $pName !== '' && !isset( $workspace['pages'][$pName] ) ) {
$title = Title::newFromText( $pName );
$valid = $title && $title->canExist();
$exists = $valid && $title->exists();
$text = '';
if ( $valid && $template !== '' ) {
$text = str_replace(
['$1', '$2', '$3'],
[$v1, $v2, $v3],
$template
);
}
$workspace['pages'][$pName] = [
'valid' => $valid,
'exists' => $exists,
'var1' => $v1,
'var2' => $v2,
'var3' => $v3,
'text' => $text
];
}
}
}
$this->saveWorkspace( $workspace );
// ==== ВЫПОЛНЕНИЕ СОЗДАНИЯ СТРАНИЦ ====
if ( $request->getVal( 'action_execute' ) ) {
$selectedCb = $request->getArray( 'mass_execute_cb', [] );
$selectedLookup = array_flip( $selectedCb );
$successCount = 0; $errors = [];
$updaterFactory = MediaWikiServices::getInstance()->getPageUpdaterFactory();
$contentHandlerFactory = MediaWikiServices::getInstance()->getContentHandlerFactory();
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 );
$exists = $title->exists();
if ( $exists && !$workspace['overwrite'] ) {
$errors[] = "$pName — " . $this->msg('batchtools-error-page-exists')->text();
continue;
}
$action = $exists ? 'edit' : 'createpage';
$errKey = $this->checkPagePermissions( $action, $title );
if ( $errKey !== null ) {
$errors[] = "$pName — " . $this->msg($errKey)->text();
continue;
}
$text = $pData['text'];
if ( trim($text) === '' ) {
$errors[] = "$pName — " . $this->msg('batchtools-error-empty-text')->text();
continue;
}
try {
$updater = $updaterFactory->newPageUpdater( $title, $user );
// Безопасное создание контента через API MW
$content = $contentHandlerFactory->getContentHandler( $title->getContentModel() )->unserializeContent( $text );
$updater->setContent( 'main', $content );
$flags = $exists ? EDIT_UPDATE : EDIT_NEW;
if ( $authority->isAllowed('bot') ) {
$flags |= EDIT_FORCE_BOT;
}
$comment = \CommentStoreComment::newUnsavedComment( $workspace['reason'] );
$updater->saveRevision( $comment, $flags );
if ( $updater->wasSuccessful() ) {
$successCount++;
} else {
$status = $updater->getStatus();
$errors[] = "$pName — " . $this->formatStatusError( $status );
}
} catch ( \Throwable $e ) {
// Throwable ловит как стандартные Exception, так и критические Error (опечатки, типы)
$errors[] = "$pName — " . $this->msg('batchtools-error-exception', $e->getMessage())->text();
}
}
$request->getSession()->set( 'batchtools_result', [
'action' => 'createpage', 'success_count' => $successCount, 'errors' => $errors
] );
$this->clearWorkspace();
$out->redirect( $this->getPageTitle()->getFullURL( [ 'view' => 'createpage' ] ) );
return;
}
}
// ==== РЕНДЕРИНГ ИНТЕРФЕЙСА ====
$formAction = $this->getPageTitle()->getLocalURL( [ 'view' => 'createpage' ] );
$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-createpage-add-pages' )->text() . '</h4>';
$html .= '<div style="display:flex; gap: 20px; flex-wrap: wrap;">';
$html .= '<div style="flex: 1; min-width: 250px;">';
$pageInput = new \OOUI\MultilineTextInputWidget([
'name' => 'new_pages_list', 'rows' => 6, 'placeholder' => $this->msg( 'batchtools-createpage-placeholder' )->text(), 'infusable' => true
]);
$addBtn = new \OOUI\ButtonInputWidget([
'type' => 'submit', 'name' => 'action_add_pages', 'value' => '1',
'label' => $this->msg( 'batchtools-createpage-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: 400px; display:flex; flex-direction:column; gap: 10px;">';
$html .= '<div><b>' . $this->msg('batchtools-createpage-qs-text')->text() . '</b><br>';
$qsTextInput = new \OOUI\MultilineTextInputWidget([
'name' => 'qs_text', 'id' => 'qs_text', 'rows' => 5, 'value' => $workspace['common_text'], 'infusable' => true
]);
$html .= $qsTextInput->toString() . '</div>';
$overwriteChecked = $workspace['overwrite'] ? ' checked' : '';
$html .= '<div><label><input type="checkbox" name="mass_overwrite" value="1"' . $overwriteChecked . '> ' . $this->msg('batchtools-createpage-overwrite')->text() . '</label></div>';
$html .= '<div style="margin-top: auto;"><button type="button" id="bt-createpages-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></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-createpage-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-createpage-th-page' )->text() . '</th>';
$html .= '<th style="width: 15%;">' . $this->msg( 'batchtools-createpage-th-var1' )->text() . '</th>';
$html .= '<th style="width: 15%;">' . $this->msg( 'batchtools-createpage-th-var2' )->text() . '</th>';
$html .= '<th style="width: 15%;">' . $this->msg( 'batchtools-createpage-th-var3' )->text() . '</th>';
$html .= '<th style="width: 120px;">' . $this->msg( 'batchtools-protect-th-action' )->text() . '</th></tr>';
foreach ( $workspace['pages'] as $pName => $pData ) {
$b64 = base64_encode( $pName );
$html .= '<tr class="bt-main-row">';
if ( $pData['valid'] ) {
$existsWarning = $pData['exists'] ? ' <br><span style="color:#d33; font-size:0.85em;">(' . $this->msg('batchtools-error-page-exists')->escaped() . ')</span>' : '';
$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>' . $existsWarning . '</td>';
$html .= '<td><input type="text" name="pages_data['.$b64.'][var1]" value="' . htmlspecialchars($pData['var1']) . '" class="mw-ui-input bt-var-input" data-var="1"></td>';
$html .= '<td><input type="text" name="pages_data['.$b64.'][var2]" value="' . htmlspecialchars($pData['var2']) . '" class="mw-ui-input bt-var-input" data-var="2"></td>';
$html .= '<td><input type="text" name="pages_data['.$b64.'][var3]" value="' . htmlspecialchars($pData['var3']) . '" class="mw-ui-input bt-var-input" data-var="3"></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="5" style="padding: 10px;">';
$html .= '<textarea name="pages_data['.$b64.'][text]" class="mw-ui-input bt-text-input" rows="5" style="font-family: monospace;">' . htmlspecialchars($pData['text']) . '</textarea>';
$html .= '</td></tr>';
} else {
$html .= '<td style="text-align:center;"><input type="checkbox" disabled></td>';
$html .= '<td colspan="4"><strike>' . htmlspecialchars( $pName ) . '</strike> <span style="color:#d33; font-size:0.9em;">(' . $this->msg('batchtools-error-invalid-title')->escaped() . ')</span></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="5"></td></tr>';
}
}
$html .= '</table>';
} else {
$html .= '<div class="warningbox">' . $this->msg( 'batchtools-createpage-empty-table' )->text() . '</div>';
}
$html .= '</form>';
// JS для живой замены переменных в тексте
$html .= '<script>
document.addEventListener("DOMContentLoaded", function () {
var qsTextNode = document.querySelector("textarea[name=\'qs_text\']");
function updateRowText(trMain) {
if (!qsTextNode) return;
var qsText = qsTextNode.value;
if (qsText.trim() === "") return;
var trDetail = trMain.nextElementSibling;
if (!trDetail) return;
var i1 = trMain.querySelector(\'input[data-var="1"]\');
var i2 = trMain.querySelector(\'input[data-var="2"]\');
var i3 = trMain.querySelector(\'input[data-var="3"]\');
var v1 = i1 ? i1.value : "";
var v2 = i2 ? i2.value : "";
var v3 = i3 ? i3.value : "";
// Безопасная замена без инъекций
var newText = qsText.split("$1").join(v1)
.split("$2").join(v2)
.split("$3").join(v3);
var textInput = trDetail.querySelector(".bt-text-input");
if (textInput) {
textInput.value = newText;
}
}
document.querySelectorAll(".bt-var-input").forEach(function(input) {
input.addEventListener("input", function() {
updateRowText(this.closest("tr.bt-main-row"));
});
});
var btnSync = document.getElementById("bt-createpages-sync");
if (btnSync) {
btnSync.addEventListener("click", function(e) {
e.preventDefault();
document.querySelectorAll(".bt-cb:checked").forEach(function(cb) {
updateRowText(cb.closest("tr.bt-main-row"));
});
});
}
});
</script>';
$out->addHTML( $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 );
}
}
MassUploadHandler.php
<?php
namespace MediaWiki\Extension\BatchTools\Handlers;
use MediaWiki\Html\Html;
use MediaWiki\Title\Title;
class MassUploadHandler extends BatchToolHandler {
public function execute() {
$out = $this->getOutput();
$request = $this->getRequest();
$user = $this->getUser();
$authority = $this->getAuthority();
if ( !$authority->isAllowed( 'uploadbatch' ) ) {
$out->addHTML( Html::errorBox( $this->msg( 'batchtools-error-nopermissions' )->text() ) );
return;
}
// ==== ОБРАБОТКА POST-ЗАПРОСОВ ====
if ( $request->wasPosted() && $user->matchEditToken( $request->getVal( 'wpEditToken' ) ) && $request->getVal( 'action_execute' ) ) {
$successCount = 0; $errors = [];
$ignoreWarnings = $request->getCheck( 'mass_ignorewarnings' );
$reason = trim( $request->getVal( 'mass_reason', '' ) );
$filesData = $request->getArray( 'files_data', [] );
foreach ( $filesData as $fileId => $data ) {
$uploadRequest = $request->getUpload( "file_{$fileId}" );
if ( !$uploadRequest ) continue;
// Получаем и очищаем конечное имя файла
$destName = trim( $data['name'] ?? '' );
if ( $destName === '' ) {
$destName = $uploadRequest->getName();
}
// Убираем префикс Файл/Изображение, если пользователь скопировал его вместе с именем
$destName = preg_replace( '/^(File|Image|Файл|Изображение):/i', '', $destName );
$title = Title::makeTitleSafe( NS_FILE, $destName );
if ( !$title ) {
$errors[] = "$destName — invalid title.";
continue;
}
// Используем правильное имя с нормализацией MediaWiki
$destName = $title->getDBkey();
$errKey = $this->checkPagePermissions( 'upload', $title );
if ( $errKey !== null ) {
$errors[] = "$destName — " . $this->msg( $errKey )->text();
continue;
}
try {
// API MediaWiki для работы с загрузками
$upload = new \UploadFromFile();
$upload->initialize( $destName, $uploadRequest );
$verification = $upload->verifyUpload();
// Константа UploadBase::OK равна 0
if ( $verification['status'] !== 0 ) {
$errors[] = "$destName — Verification failed (code: " . $verification['status'] . ")";
continue;
}
if ( !$ignoreWarnings ) {
$warnings = $upload->checkWarnings( $user );
if ( !empty( $warnings ) ) {
$warnKeys = implode( ', ', array_keys( $warnings ) );
$errors[] = "$destName — Warnings: $warnKeys";
continue;
}
}
$text = $data['text'] ?? '';
$status = $upload->performUpload( $reason, $text, false, $user );
if ( $status->isOK() ) {
$successCount++;
} else {
$errors[] = "$destName — " . $this->formatStatusError( $status );
}
} catch ( \Exception $e ) {
$errors[] = "$destName — " . $this->msg( 'batchtools-error-exception', $e->getMessage() )->text();
}
}
$request->getSession()->set( 'batchtools_result', [
'action' => 'upload', 'success_count' => $successCount, 'errors' => $errors
] );
$out->redirect( $this->getPageTitle()->getFullURL( [ 'view' => 'upload' ] ) );
return;
}
// ==== РЕНДЕРИНГ ИНТЕРФЕЙСА ====
$formAction = $this->getPageTitle()->getLocalURL( [ 'view' => 'upload' ] );
$html = '<form method="post" enctype="multipart/form-data" id="mass-upload-form" action="' . htmlspecialchars( $formAction ) . '">';
$html .= Html::hidden( 'wpEditToken', $user->getEditToken() );
$html .= Html::hidden( 'action_execute', '1' );
// 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-upload-add-files' )->text() . '</h4>';
$html .= '<div style="display:flex; gap: 20px; flex-wrap: wrap;">';
// Левая колонка: Кнопка выбора и галочка игнора
$html .= '<div style="flex: 1; min-width: 250px;">';
$html .= '<input type="file" id="master-file-input" multiple accept="*/*" style="display:none;">';
$addBtn = new \OOUI\ButtonWidget([
'id' => 'btn-select-files',
'label' => $this->msg( 'batchtools-upload-add-files' )->text(),
'flags' => [ 'progressive' ],
'icon' => 'upload',
'infusable' => true
]);
$html .= $addBtn->toString();
$html .= '<div style="margin-top: 15px;">';
$html .= '<label><input type="checkbox" name="mass_ignorewarnings" value="1" checked> ' . $this->msg( 'batchtools-upload-ignorewarnings' )->text() . '</label>';
$html .= '</div>';
$html .= '</div>';
// Правая колонка: Общий текст (Описание)
$html .= '<div style="flex: 2; min-width: 400px; display:flex; flex-direction:column; gap: 10px;">';
$html .= '<div><b>' . $this->msg( 'batchtools-upload-qs-text' )->text() . '</b><br>';
$qsTextInput = new \OOUI\MultilineTextInputWidget([
'name' => 'qs_text', 'id' => 'qs_text', 'rows' => 4, 'infusable' => true
]);
$html .= $qsTextInput->toString() . '</div>';
$html .= '<div style="margin-top: auto;"><button type="button" id="bt-upload-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></div></div>';
// 2. БЛОК ВЫПОЛНЕНИЯ И ТАБЛИЦА (Управляется через JavaScript)
$html .= '<div id="mass-upload-empty-msg" class="warningbox">' . $this->msg( 'batchtools-upload-empty-table' )->text() . '</div>';
$html .= '<div id="mass-upload-table-wrap" style="display:none;">';
$reasonInput = new \OOUI\TextInputWidget([ 'name' => 'mass_reason', 'infusable' => true ]);
$execBtn = new \OOUI\ButtonInputWidget([ 'type' => 'submit', 'id' => 'btn-upload-execute', 'label' => $this->msg( 'batchtools-upload-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>';
$html .= '<div style="margin-bottom: 10px; display:flex; gap: 10px;">';
$html .= '<button type="button" id="btn-remove-checked" class="mw-ui-button mw-ui-destructive mw-ui-quiet">' . $this->msg( 'batchtools-protect-btn-del-checked' )->escaped() . '</button>';
$html .= '<button type="button" id="btn-clear-all" class="mw-ui-button mw-ui-destructive mw-ui-quiet">' . $this->msg( 'batchtools-protect-btn-clear-all' )->escaped() . '</button>';
$html .= '</div>';
$html .= '<table id="mass-upload-table" class="wikitable" style="width: 100%;">';
$html .= '<thead><tr>';
$html .= '<th style="width: 30px; text-align:center;"><input type="checkbox" checked onclick="document.querySelectorAll(\'#mass-upload-table .bt-cb\').forEach(cb => cb.checked = this.checked);"></th>';
$html .= '<th style="width: 20%;">' . $this->msg( 'batchtools-upload-th-file' )->text() . '</th>';
$html .= '<th style="width: 25%;">' . $this->msg( 'batchtools-upload-th-name' )->text() . '</th>';
$html .= '<th>' . $this->msg( 'batchtools-upload-th-text' )->text() . '</th>';
$html .= '<th style="width: 80px;">' . $this->msg( 'batchtools-protect-th-action' )->text() . '</th>';
$html .= '</tr></thead><tbody></tbody></table>';
$html .= '</div>';
$html .= '</form>';
$btnDeleteText = $this->msg( 'batchtools-btn-delete' )->escaped();
// Кастомный JS, превращающий форму в реактивный интерфейс
$html .= "<script>
document.addEventListener('DOMContentLoaded', function() {
let fileIdCounter = 0;
const masterInput = document.getElementById('master-file-input');
const tableBody = document.querySelector('#mass-upload-table tbody');
const tableWrap = document.getElementById('mass-upload-table-wrap');
const emptyMsg = document.getElementById('mass-upload-empty-msg');
const btnSelect = document.getElementById('btn-select-files');
const btnSync = document.getElementById('bt-upload-sync');
const btnClearAll = document.getElementById('btn-clear-all');
const btnRemoveChecked = document.getElementById('btn-remove-checked');
const form = document.getElementById('mass-upload-form');
if (btnSelect) {
btnSelect.addEventListener('click', function(e) {
e.preventDefault();
masterInput.click();
});
}
if (masterInput) {
masterInput.addEventListener('change', function() {
Array.from(this.files).forEach(file => {
const fileId = fileIdCounter++;
const tr = document.createElement('tr');
tr.className = 'bt-main-row';
// 1. Чекбокс
const tdCb = document.createElement('td');
tdCb.style.textAlign = 'center';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'bt-cb';
cb.checked = true;
tdCb.appendChild(cb);
// 2. Инфо о файле + Скрытый input[type=file]
const tdFile = document.createElement('td');
let sizeStr = (file.size / 1024).toFixed(1) + ' KB';
if (file.size > 1024 * 1024) sizeStr = (file.size / (1024 * 1024)).toFixed(2) + ' MB';
tdFile.innerHTML = '<b>' + file.name + '</b><br><small>' + sizeStr + '</small>';
const hiddenFileInput = document.createElement('input');
hiddenFileInput.type = 'file';
hiddenFileInput.name = 'file_' + fileId;
hiddenFileInput.style.display = 'none';
// Магия: переносим файл из мастер-инпута в отдельный инпут
try {
const dt = new DataTransfer();
dt.items.add(file);
hiddenFileInput.files = dt.files;
} catch (e) {
console.warn('DataTransfer API не поддерживается', e);
}
tdFile.appendChild(hiddenFileInput);
// 3. Конечное имя
const tdName = document.createElement('td');
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.name = 'files_data[' + fileId + '][name]';
nameInput.value = file.name;
nameInput.className = 'mw-ui-input bt-name-input';
tdName.appendChild(nameInput);
// 4. Описание
const tdText = document.createElement('td');
const textInput = document.createElement('textarea');
textInput.name = 'files_data[' + fileId + '][text]';
textInput.className = 'mw-ui-input bt-text-input';
textInput.rows = 2;
textInput.style.fontFamily = 'monospace';
tdText.appendChild(textInput);
// 5. Кнопка удаления
const tdAction = document.createElement('td');
const btnRemove = document.createElement('button');
btnRemove.type = 'button';
btnRemove.className = 'mw-ui-button mw-ui-destructive mw-ui-quiet';
btnRemove.textContent = '{$btnDeleteText}';
btnRemove.onclick = function() {
tr.remove();
updateVisibility();
};
tdAction.appendChild(btnRemove);
tr.appendChild(tdCb);
tr.appendChild(tdFile);
tr.appendChild(tdName);
tr.appendChild(tdText);
tr.appendChild(tdAction);
tableBody.appendChild(tr);
});
this.value = ''; // Сбрасываем инпут, чтобы можно было выбрать те же файлы снова
updateVisibility();
});
}
if (btnSync) {
btnSync.addEventListener('click', function(e) {
e.preventDefault();
const qsTextInput = document.querySelector('textarea[name=\"qs_text\"]');
const qsText = qsTextInput ? qsTextInput.value : '';
if (qsText !== '') {
document.querySelectorAll('.bt-cb:checked').forEach(cb => {
const tr = cb.closest('tr');
const textInput = tr.querySelector('.bt-text-input');
if (textInput) textInput.value = qsText;
});
}
});
}
if (btnClearAll) {
btnClearAll.addEventListener('click', function() {
tableBody.innerHTML = '';
updateVisibility();
});
}
if (btnRemoveChecked) {
btnRemoveChecked.addEventListener('click', function() {
document.querySelectorAll('.bt-cb:checked').forEach(cb => {
cb.closest('tr').remove();
});
updateVisibility();
});
}
function updateVisibility() {
const rowCount = tableBody.querySelectorAll('tr').length;
if (rowCount > 0) {
tableWrap.style.display = 'block';
emptyMsg.style.display = 'none';
} else {
tableWrap.style.display = 'none';
emptyMsg.style.display = 'block';
}
}
if (form) {
form.addEventListener('submit', function() {
// Отключаем input-элементы в невыделенных строках, чтобы они не отправлялись на сервер
document.querySelectorAll('.bt-cb:not(:checked)').forEach(cb => {
const tr = cb.closest('tr');
tr.querySelectorAll('input, textarea').forEach(el => el.disabled = true);
});
});
}
});
</script>";
$out->addHTML( $html );
}
}
| BatchTools | |||
|---|---|---|---|
|
|||