MediaWiki:Gadget-MassRollback.js: Difference between revisions

From Test Wiki
Jump to navigation Jump to search
Content deleted Content added
Xaloria (talk | contribs)
Created page with "$(document).ready(function () { if (mw.config.get('wgCanonicalSpecialPageName') !== 'Contributions') { return; } // Dodaj nowy rozwijany box if ($('#rollback-box').length === 0) { var $rollbackBox = $(` <div id="rollback-box" class="mw-htmlform-ooui-wrapper oo-ui-layout oo-ui-panelLayout oo-ui-panelLayout-padded oo-ui-panelLayout-framed"> <form id="rollback-form" class="mw-htmlform mw-htmlform-ooui oo-ui-layout..."
 
BZPN (talk | contribs)
No edit summary
Tags: Mobile edit Mobile web edit
 
(2 intermediate revisions by 2 users not shown)
Line 1: Line 1:
(function($, mw) {
$(document).ready(function () {
'use strict';
if (mw.config.get('wgCanonicalSpecialPageName') !== 'Contributions') {
return;
}


const MassRollback = {
// Dodaj nowy rozwijany box

if ($('#rollback-box').length === 0) {
var $rollbackBox = $(`
init: function() {
if (mw.config.get('wgCanonicalSpecialPageName') !== 'Contributions') {
<div id="rollback-box" class="mw-htmlform-ooui-wrapper oo-ui-layout oo-ui-panelLayout oo-ui-panelLayout-padded oo-ui-panelLayout-framed">
return;
<form id="rollback-form" class="mw-htmlform mw-htmlform-ooui oo-ui-layout oo-ui-formLayout">
}
<fieldset class="oo-ui-layout oo-ui-labelElement oo-ui-fieldsetLayout mw-collapsibleFieldsetLayout mw-collapsible mw-made-collapsible">
this.createUI();
<legend role="button" class="oo-ui-fieldsetLayout-header mw-collapsible-toggle mw-collapsible-toggle-expanded" aria-expanded="true" tabindex="0">
this.bindEvents();
<span class="oo-ui-iconElement-icon oo-ui-iconElement-noIcon"></span>
this.fetchUserStats();
<span class="oo-ui-labelElement-label">Contributions rollback</span>
},
<span class="oo-ui-widget oo-ui-widget-enabled oo-ui-iconElement-icon oo-ui-icon-expand oo-ui-iconElement oo-ui-labelElement-invisible oo-ui-iconWidget">Expand</span>

<span class="oo-ui-widget oo-ui-widget-enabled oo-ui-iconElement-icon oo-ui-icon-collapse oo-ui-iconElement oo-ui-labelElement-invisible oo-ui-iconWidget">Collapse</span>
createUI: function() {
</legend>
const $container = $(`
<div class="oo-ui-fieldsetLayout-group mw-collapsible-content" style="display: block;">
<div id="mass-rollback-container" style="border: 1px solid #ccc; padding: 15px; margin-bottom: 20px; border-radius: 4px; background: #f9f9f9; display: none;">
<div class="oo-ui-widget oo-ui-widget-enabled">
<label for="rollback-reason" class="oo-ui-labelElement-label">Reason for reverting edits (optional):</label>
<h3 style="margin-top: 0;">Mass Rollback Tool</h3>
<select id="rollback-reason" class="oo-ui-inputWidget-input oo-ui-dropdownWidget">
<option value="">-- Select a reason --</option>
<div id="stats-section" style="margin-bottom: 15px;">
<option value="Test">Test</option>
<h4>User statistics</h4>
<option value="Vandalism">Vandalism</option>
<p>Total edits: <strong id="total-edits">-</strong></p>
<option value="Spam">Spam</option>
<p>First edit: <span id="first-edit">-</span></p>
<p>Latest edit: <span id="last-edit">-</span> <button id="refresh-stats" style="padding: 3px 8px; background-color: #aaa; color: #fff; border: none; border-radius: 3px; cursor: pointer;">Refresh</button></p>
<option value="Revert">Revert</option>
</select>
</div>
<input type="text" id="rollback-summary" placeholder="Reason (optional)" class="oo-ui-inputWidget-input oo-ui-textInputWidget-input" style="margin-top: 10px; padding: 5px; width: 100%;"/>
</div>
<hr style="margin: 15px 0;">
<div class="oo-ui-widget oo-ui-widget-enabled mw-htmlform-submit-buttons">
<div id="filters-section" style="margin-bottom: 15px;">
<button id="rollback-selected-button" class="oo-ui-inputWidget-input oo-ui-buttonElement-button oo-ui-buttonElement-framed oo-ui-flaggedElement-progressive" style="padding: 5px 10px; background-color: #007bff; color: #fff; border: none; cursor: pointer; border-radius: 4px;">Revert selected edits</button>
<h4>Filter Edits</h4>
<button id="rollback-all-button" class="oo-ui-inputWidget-input oo-ui-buttonElement-button oo-ui-buttonElement-framed oo-ui-flaggedElement-destructive" style="padding: 5px 10px; background-color: #ff4136; color: #fff; border: none; cursor: pointer; border-radius: 4px;">Revert all edits</button>
<div style="margin-bottom: 10px;">
<button id="cancel-rollback" class="oo-ui-inputWidget-input oo-ui-buttonElement-button oo-ui-buttonElement-framed" style="padding: 5px 10px; background-color: #aaa; color: #fff; border: none; cursor: pointer; border-radius: 4px;">Cancel</button>
</div>
<label style="margin-right: 5px;">Date From:</label>
<input type="date" id="start-date" style="padding: 4px;">
<label style="margin: 0 5px;">To:</label>
<input type="date" id="end-date" style="padding: 4px;">
</div>
</div>
</fieldset>
<div style="margin-bottom: 10px;">
</form>
<label style="margin-right: 5px;">Namespace:</label>
<select id="namespace-filter" multiple style="padding: 4px; min-width: 150px;">
</div>
<option value="all">All</option>
`);
<option value="0">Main</option>
<option value="1">Talk</option>
<option value="2">User</option>
<option value="3">User talk</option>
<option value="4">Project</option>
<option value="5">Project talk</option>
<option value="6">File</option>
<option value="7">File talk</option>
<option value="8">MediaWiki</option>
<option value="9">MediaWiki talk</option>
<option value="10">Template</option>
<option value="11">Template talk</option>
<option value="12">Help</option>
<option value="13">Help talk</option>
<option value="14">Category</option>
<option value="15">Category talk</option>
<option value="100">Portal</option>
<option value="101">Portal talk</option>
</select>
</div>
<div style="margin-bottom: 10px;">
<label style="margin-right: 5px;">Edit Size:</label>
<select id="size-filter" style="padding: 4px;">
<option value="">Any</option>
<option value="small">Small (&lt; 50 bytes)</option>
<option value="medium">Medium (50-500 bytes)</option>
<option value="large">Large (&gt; 500 bytes)</option>
</select>
</div>
<div style="margin-bottom: 10px;">
<label style="margin-right: 5px;">Sort Order:</label>
<select id="sort-order" style="padding: 4px;">
<option value="desc">Latest first</option>
<option value="asc">Oldest first</option>
</select>
</div>
<div style="margin-bottom: 10px;">
<button id="clear-filters" style="padding: 5px 10px; background-color: #aaa; color: #fff; border: none; border-radius: 3px; cursor: pointer;">Clear filters</button>
<button id="show-filtered" style="padding: 5px 10px; background-color: #007bff; color: #fff; border: none; border-radius: 3px; cursor: pointer; margin-left: 5px;">Show filtered edits</button>
</div>
</div>
<hr style="margin: 15px 0;">
<div id="reason-section" style="margin-bottom: 15px;">
<h4>Rollback reason</h4>
<select id="rollback-reason" style="padding: 4px;">
<option value=""></option>
<option value="vandalism">Vandalism</option>
<option value="spam">Spam</option>
<option value="test">Test rollback</option>
<option value="teste">Test edit</option>
<option value="rules-violation">Violation</option>
<option value="other">Other</option>
</select>
<input type="text" id="custom-reason" placeholder="Enter custom reason" style="display:none; padding: 4px; margin-top: 10px; width: 100%;">
</div>
<div id="actions-section" style="text-align: center;">
<button id="rollback-selected" style="padding: 5px 10px; background-color: #007bff; color: #fff; border: none; border-radius: 3px; cursor: pointer; margin-right: 5px;">Rollback selected</button>
<button id="rollback-all" style="padding: 5px 10px; background-color: #ff4136; color: #fff; border: none; border-radius: 3px; cursor: pointer;">Rollback all</button>
</div>
</div>
<div id="filtered-edits-container" style="display:none; border: 1px solid #ccc; padding: 15px; margin-bottom:20px; border-radius: 4px; background: #f9f9f9;">
<h4>Filtered edits</h4>
<div style="margin-bottom: 10px;">
<input type="checkbox" id="select-all"> <label for="select-all">Select All</label>
</div>
<table id="filtered-edits-table" style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: #e9e9e9;">
<th style="padding: 5px; border: 1px solid #ccc;">Revid</th>
<th style="padding: 5px; border: 1px solid #ccc;">Title</th>
<th style="padding: 5px; border: 1px solid #ccc;">Timestamp</th>
<th style="padding: 5px; border: 1px solid #ccc;">Namespace</th>
<th style="padding: 5px; border: 1px solid #ccc;">Size</th>
<th style="padding: 5px; border: 1px solid #ccc;">Select</th>
</tr>
</thead>
<tbody>
<!-- Wstawiane dynamicznie -->
</tbody>
</table>
<div style="text-align: center; margin-top: 10px;">
<button id="rollback-selected-filtered" style="padding: 5px 10px; background-color: #007bff; color: #fff; border: none; border-radius: 3px; cursor: pointer;">Rollback selected filtered edits</button>
</div>
</div>
`);
const $toggleButton = $(`
<button id="mass-rollback-toggle" style="padding: 5px 10px; margin-bottom: 10px; background-color: #007bff; color: #fff; border: none; border-radius: 3px; cursor: pointer;">
Show/Hide MassRollback
</button>
`);
$toggleButton.on('click', function() {
$('#mass-rollback-container').slideToggle();
});
$('#mw-content-text').prepend($toggleButton).prepend($container);
},


bindEvents: function() {
$('#mw-content-text').prepend($rollbackBox);
$('#rollback-reason').on('change', function() {
$('#custom-reason').toggle($(this).val() === 'other');
});


$('#clear-filters').on('click', function() {
// Synchronize dropdown with input field
$('#rollback-reason').change(function () {
$('#start-date').val('');
var selectedReason = $(this).val();
$('#end-date').val('');
var currentSummary = $('#rollback-summary').val();
$('#namespace-filter').val(['all']);
if (!currentSummary) {
$('#size-filter').val('');
$('#rollback-summary').val(selectedReason);
$('#sort-order').val('desc');
}
});
});


$('#refresh-stats').on('click', this.fetchUserStats.bind(this));
// 1
$('#show-filtered').on('click', this.displayFilteredEdits.bind(this));
$('#rollback-box .mw-collapsible-toggle').click(function () {
$('#rollback-selected-filtered').on('click', this.rollbackSelectedFromFiltered.bind(this));
var $content = $(this).closest('fieldset').find('.mw-collapsible-content');
$('#rollback-selected').on('click', this.rollbackSelected.bind(this));
var isExpanded = $(this).attr('aria-expanded') === 'true';
$('#rollback-all').on('click', this.rollbackAll.bind(this));


if (isExpanded) {
$(document).on('change', '#select-all', function() {
$content.slideUp();
const isChecked = $(this).is(':checked');
$(this).attr('aria-expanded', 'false');
$('#filtered-edits-table tbody input[type="checkbox"]').prop('checked', isChecked);
$(this).find('.oo-ui-icon-collapse').hide();
});
$(this).find('.oo-ui-icon-expand').show();
} else {
$content.slideDown();
$(this).attr('aria-expanded', 'true');
$(this).find('.oo-ui-icon-expand').hide();
$(this).find('.oo-ui-icon-collapse').show();
}
});


// Dodaj checkbox przy edycjach w widoku kontrybucji
// Rollback selected
$('#rollback-selected-button').click(function () {
$('li[data-mw-revid]').each(function() {
var userName = mw.config.get('wgRelevantUserName');
const $li = $(this);
var summary = $('#rollback-summary').val();
const revid = $li.data('mw-revid');
var api = new mw.Api();
const parentid = $li.data('mw-prev-revid');
const title = $li.find('.mw-contributions-title').text().trim();
const $checkbox = $('<input>', {
type: 'checkbox',
class: 'rollback-checkbox',
'data-revid': revid,
'data-parentid': parentid,
'data-title': title,
style: 'margin-right: 5px;'
});
$li.prepend($checkbox);
});
},


fetchUserStats: function() {
var selectedRevisions = $('input[type="checkbox"].rollback-checkbox:checked').map(function () {
return {
const userName = mw.config.get('wgRelevantUserName');
title: $(this).data('title'),
const api = new mw.Api();
revid: $(this).data('revid'),
api.get({
parentid: $(this).data('parentid')
action: 'query',
};
list: 'usercontribs',
}).get();
ucuser: userName,
uclimit: 'max',
ucprop: 'timestamp'
}).done(function(data) {
const contributions = data.query.usercontribs;
$('#total-edits').text(contributions.length);
if (contributions.length > 0) {
const timestamps = contributions.map(c => new Date(c.timestamp)).sort((a, b) => a - b);
$('#first-edit').text(timestamps[0].toLocaleDateString());
$('#last-edit').text(timestamps[timestamps.length - 1].toLocaleDateString());
}
});
},


getNamespaceName: function(ns) {
if (selectedRevisions.length === 0) {
const nsMapping = {
alert('Nie zaznaczono żadnych edycji do cofnięcia.');
return;
0: 'Main',
}
1: 'Talk',
2: 'User',
3: 'User talk',
4: 'Project',
5: 'Project talk',
6: 'File',
7: 'File talk',
8: 'MediaWiki',
9: 'MediaWiki talk',
10: 'Template',
11: 'Template talk',
12: 'Help',
13: 'Help talk',
14: 'Category',
15: 'Category talk',
100: 'Portal',
101: 'Portal talk'
};
return nsMapping[ns] || ns;
},


var rollbackPromises = selectedRevisions.map(function (contrib) {
applyFilters: function(contributions) {
const startDate = $('#start-date').val() ? new Date($('#start-date').val()) : null;
var formattedSummary = `Reverted edit by [[User:${userName}|${userName}]] using [[Gadget:MassRollback|gadget]]` + (summary ? `: ${summary}` : '');
const endDate = $('#end-date').val() ? new Date($('#end-date').val()) : null;
let namespaceFilter = $('#namespace-filter').val() || [];
const sizeFilter = $('#size-filter').val();
const sortOrder = $('#sort-order').val();


return api.postWithToken('csrf', {
if (namespaceFilter.includes('all')) {
action: 'edit',
namespaceFilter = [];
undoafter: contrib.parentid,
}

undo: contrib.revid,
title: contrib.title,
let filtered = contributions.filter(contrib => {
summary: formattedSummary
const contribDate = new Date(contrib.timestamp);
});
if (startDate && contribDate < startDate) return false;
if (endDate && contribDate > endDate) return false;
if (namespaceFilter.length > 0 && !namespaceFilter.includes(contrib.ns.toString())) return false;
if (sizeFilter) {
const size = contrib.size || 0;
switch (sizeFilter) {
case 'small': return size < 50;
case 'medium': return size >= 50 && size <= 500;
case 'large': return size > 500;
}
}
return true;
});
});


Promise.all(rollbackPromises).then(function () {
filtered.sort((a, b) => {
alert('Zaznaczone edycje zostały wycofane!');
return sortOrder === 'asc'
location.reload();
? new Date(a.timestamp) - new Date(b.timestamp)
: new Date(b.timestamp) - new Date(a.timestamp);
}).catch(function (error) {
console.error('An error occured when reverting changes:', error);
alert('Wystąpił błąd podczas wycofywania edycji.');
});
});
});


// Rollback all
return filtered;
},
$('#rollback-all-button').click(function () {
var userName = mw.config.get('wgRelevantUserName');
var summary = $('#rollback-summary').val();
var api = new mw.Api();


displayFilteredEdits: function() {
const userName = mw.config.get('wgRelevantUserName');
const api = new mw.Api();
api.get({
api.get({
action: 'query',
action: 'query',
Line 119: Line 272:
ucuser: userName,
ucuser: userName,
uclimit: 'max',
uclimit: 'max',
ucprop: 'ids|title',
ucprop: 'ids|title|timestamp|size|ns|parentid'
format: 'json'
}).done((data) => {
const allContributions = data.query.usercontribs;
}).done(function (data) {
var contributions = data.query.usercontribs;
const filtered = this.applyFilters(allContributions);
const $tbody = $('#filtered-edits-table tbody');
$tbody.empty();


if (contributions.length === 0) {
if (filtered.length === 0) {
alert('No edits to revert.');
$tbody.append('<tr><td colspan="6" style="text-align:center; padding:5px;">No edits match the selected filters.</td></tr>');
return;
} else {
filtered.forEach(contrib => {
const row = `
<tr>
<td style="padding: 5px; border: 1px solid #ccc;">${contrib.revid}</td>
<td style="padding: 5px; border: 1px solid #ccc;">${contrib.title}</td>
<td style="padding: 5px; border: 1px solid #ccc;">${new Date(contrib.timestamp).toLocaleString()}</td>
<td style="padding: 5px; border: 1px solid #ccc;">${this.getNamespaceName(contrib.ns)}</td>
<td style="padding: 5px; border: 1px solid #ccc;">${contrib.size || 0}</td>
<td style="padding: 5px; border: 1px solid #ccc; text-align: center;">
<input type="checkbox" class="filtered-rollback-checkbox" data-revid="${contrib.revid}" data-parentid="${contrib.parentid}" data-title="${contrib.title}">
</td>
</tr>
`;
$tbody.append(row);
});
}
}
$('#filtered-edits-container').css("display", "block").slideDown();
});
},


confirmAction: function(message, count) {
var rollbackPromises = contributions.map(function (contrib) {
return new Promise((resolve) => {
var formattedSummary = `Reverted edit by [[User:${userName}|${userName}]] using [[Gadget:MassRollback|gadget]]` + (summary ? `: ${summary}` : '');
const $modal = $(`

return api.postWithToken('csrf', {
<div style="position: fixed; top: 0; left: 0; width: 100%; height: 100%;
action: 'edit',
background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 9999;">
<div style="background: #fff; padding: 20px; border-radius: 4px; max-width: 400px; width: 90%;">
undoafter: contrib.parentid,
undo: contrib.revid,
<h4 style="margin-top:0;">Confirm Rollback</h4>
title: contrib.title,
<p>${message}</p>
summary: formattedSummary
<p>Number of edits: ${count}</p>
});
<div style="text-align: right; margin-top: 15px;">
<button id="confirm-action" style="padding: 5px 10px; background-color: #007bff; color: #fff; border: none; border-radius: 3px; cursor: pointer; margin-right: 5px;">Confirm</button>
<button id="cancel-action" style="padding: 5px 10px; background-color: #aaa; color: #fff; border: none; border-radius: 3px; cursor: pointer;">Cancel</button>
</div>
</div>
</div>
`);
$('body').append($modal);
$modal.find('#confirm-action').on('click', function() {
$modal.remove();
resolve(true);
});
});
$modal.find('#cancel-action').on('click', function() {

Promise.all(rollbackPromises).then(function () {
$modal.remove();
alert('All edits were reverted!');
resolve(false);
location.reload();
}).catch(function (error) {
console.error('An error occured when reverting:', error);
alert('An error occured while reverting.');
});
});
}).fail(function (error) {
console.error('Error with downloading contributions:', error);
alert('Error with downloading contributions.');
});
});
});
},


// Nowa wersja: grupujemy edycje wg tytułu i dla każdej grupy wykrywamy ciąg kolejnych edycji.
$('#cancel-rollback').click(function () {
performRollback: function(contributions) {
$('#rollback-box').hide();
const userName = mw.config.get('wgRelevantUserName');
});
const reason = $('#rollback-reason').val() === 'other'
? $('#custom-reason').val()
: $('#rollback-reason option:selected').text();
const summary = `Reverted edit(s) by [[User:${userName}|${userName}]]: ${reason}`;
const api = new mw.Api();


// Dodaj checkboxy przy każdej edycji
// Grupujemy edycje wg tytułu strony
const groups = {};
$('li[data-mw-revid]').each(function () {
var $this = $(this);
contributions.forEach(contrib => {
var revid = $this.data('mw-revid');
if (!groups[contrib.title]) {
var parentid = $this.data('mw-prev-revid');
groups[contrib.title] = [];
var title = $this.find('.mw-contributions-title').text();
}
groups[contrib.title].push(contrib);
});


const promises = [];
var $checkbox = $('<input type="checkbox" class="rollback-checkbox" style="margin-right: 5px;">')
// Dla każdej strony – sortuj wg daty malejąco i wykryj ciąg edycji
.data('revid', revid)
.data('parentid', parentid)
for (let title in groups) {
.data('title', title);
let group = groups[title].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
let latest = group[0];
// Rozpoczynamy ciąg od najnowszej edycji
let chain = [latest];
let currentParent = latest.parentid;
// Przeglądamy kolejne edycje dla tego samego tytułu
for (let i = 1; i < group.length; i++) {
const contrib = group[i];
// Jeżeli kolejna edycja jest dokładnie poprzednikiem poprzednio znalezionego elementu, dodajemy do ciągu
if (parseInt(contrib.revid, 10) === parseInt(currentParent, 10)) {
chain.push(contrib);
currentParent = contrib.parentid;
} else {
break;
}
}
// Wykonaj rollback tylko, jeśli mamy przynajmniej jedną edycję (zawsze prawda)
promises.push(api.postWithToken('csrf', {
action: 'edit',
undo: latest.revid,
undoafter: currentParent,
title: title,
summary: summary
}));
}
return Promise.all(promises);
},

rollbackSelected: async function() {
// Pobieramy zaznaczone edycje z listy kontrybucji
const selected = $('.rollback-checkbox:checked').map(function() {
return {
title: $(this).data('title'),
revid: $(this).data('revid'),
parentid: $(this).data('parentid'),
// Wartość timestamp może nie być dostępna w tym widoku – można opcjonalnie dodać dodatkowy pobór danych
timestamp: $(this).closest('li').find('.mw-contributions-timestamp').text() || new Date().toISOString()
};
}).get();

if (selected.length === 0) {
mw.notify('No edits selected.', { type: 'warn' });
return;
}
const confirmed = await this.confirmAction('Are you sure you want to rollback the selected edits?', selected.length);
if (!confirmed) return;
try {
await this.performRollback(selected);
mw.notify(`Successfully rolled back ${selected.length} edits.`, { type: 'success' });
location.reload();
} catch (error) {
mw.notify('Error during rollback.', { type: 'error' });
console.error(error);
}
},

rollbackSelectedFromFiltered: async function() {
const selected = $('.filtered-rollback-checkbox:checked').map(function() {
return {
title: $(this).data('title'),
revid: $(this).data('revid'),
parentid: $(this).data('parentid'),
timestamp: new Date().toISOString() // Jeśli API już zwraca timestamp, można go tutaj użyć
};
}).get();

if (selected.length === 0) {
mw.notify('No filtered edits selected.', { type: 'warn' });
return;
}
const confirmed = await this.confirmAction('Are you sure you want to rollback the selected filtered edits?', selected.length);
if (!confirmed) return;
try {
await this.performRollback(selected);
mw.notify(`Successfully rolled back ${selected.length} filtered edits.`, { type: 'success' });
location.reload();
} catch (error) {
mw.notify('Error during rollback.', { type: 'error' });
console.error(error);
}
},

rollbackAll: async function() {
const userName = mw.config.get('wgRelevantUserName');
const api = new mw.Api();
try {
// Dodajemy "timestamp" by móc grupować edycje
const data = await api.get({
action: 'query',
list: 'usercontribs',
ucuser: userName,
uclimit: 'max',
ucprop: 'ids|title|parentid|timestamp'
});
const contributions = data.query.usercontribs;
if (contributions.length === 0) {
mw.notify('No edits to rollback.', { type: 'warn' });
return;
}
const confirmed = await this.confirmAction('Are you sure you want to rollback ALL edits?', contributions.length);
if (!confirmed) return;
await this.performRollback(contributions);
mw.notify(`Successfully rolled back all ${contributions.length} edits.`, { type: 'success' });
location.reload();
} catch (error) {
mw.notify('Error during rollback.', { type: 'error' });
console.error(error);
}
}
};


$(document).ready(function() {
$this.prepend($checkbox);
});
MassRollback.init();
}
});
})(jQuery, mediaWiki);
});

Latest revision as of 17:31, 7 April 2025

(function($, mw) {
    'use strict';

    const MassRollback = {

        init: function() {
            if (mw.config.get('wgCanonicalSpecialPageName') !== 'Contributions') {
                return;
            }
            this.createUI();
            this.bindEvents();
            this.fetchUserStats();
        },

        createUI: function() {
            const $container = $(`
                <div id="mass-rollback-container" style="border: 1px solid #ccc; padding: 15px; margin-bottom: 20px; border-radius: 4px; background: #f9f9f9; display: none;">
                    <h3 style="margin-top: 0;">Mass Rollback Tool</h3>
                    
                    <div id="stats-section" style="margin-bottom: 15px;">
                        <h4>User statistics</h4>
                        <p>Total edits: <strong id="total-edits">-</strong></p>
                        <p>First edit: <span id="first-edit">-</span></p>
                        <p>Latest edit: <span id="last-edit">-</span> <button id="refresh-stats" style="padding: 3px 8px; background-color: #aaa; color: #fff; border: none; border-radius: 3px; cursor: pointer;">Refresh</button></p>
                    </div>
                    
                    <hr style="margin: 15px 0;">
                    
                    <div id="filters-section" style="margin-bottom: 15px;">
                        <h4>Filter Edits</h4>
                        <div style="margin-bottom: 10px;">
                            <label style="margin-right: 5px;">Date From:</label>
                            <input type="date" id="start-date" style="padding: 4px;">
                            <label style="margin: 0 5px;">To:</label>
                            <input type="date" id="end-date" style="padding: 4px;">
                        </div>
                        <div style="margin-bottom: 10px;">
                            <label style="margin-right: 5px;">Namespace:</label>
                            <select id="namespace-filter" multiple style="padding: 4px; min-width: 150px;">
                                <option value="all">All</option>
                                <option value="0">Main</option>
                                <option value="1">Talk</option>
                                <option value="2">User</option>
                                <option value="3">User talk</option>
                                <option value="4">Project</option>
                                <option value="5">Project talk</option>
                                <option value="6">File</option>
                                <option value="7">File talk</option>
                                <option value="8">MediaWiki</option>
                                <option value="9">MediaWiki talk</option>
                                <option value="10">Template</option>
                                <option value="11">Template talk</option>
                                <option value="12">Help</option>
                                <option value="13">Help talk</option>
                                <option value="14">Category</option>
                                <option value="15">Category talk</option>
                                <option value="100">Portal</option>
                                <option value="101">Portal talk</option>
                            </select>
                        </div>
                        <div style="margin-bottom: 10px;">
                            <label style="margin-right: 5px;">Edit Size:</label>
                            <select id="size-filter" style="padding: 4px;">
                                <option value="">Any</option>
                                <option value="small">Small (&lt; 50 bytes)</option>
                                <option value="medium">Medium (50-500 bytes)</option>
                                <option value="large">Large (&gt; 500 bytes)</option>
                            </select>
                        </div>
                        <div style="margin-bottom: 10px;">
                            <label style="margin-right: 5px;">Sort Order:</label>
                            <select id="sort-order" style="padding: 4px;">
                                <option value="desc">Latest first</option>
                                <option value="asc">Oldest first</option>
                            </select>
                        </div>
                        <div style="margin-bottom: 10px;">
                            <button id="clear-filters" style="padding: 5px 10px; background-color: #aaa; color: #fff; border: none; border-radius: 3px; cursor: pointer;">Clear filters</button>
                            <button id="show-filtered" style="padding: 5px 10px; background-color: #007bff; color: #fff; border: none; border-radius: 3px; cursor: pointer; margin-left: 5px;">Show filtered edits</button>
                        </div>
                    </div>
                    
                    <hr style="margin: 15px 0;">
                    
                    <div id="reason-section" style="margin-bottom: 15px;">
                        <h4>Rollback reason</h4>
                        <select id="rollback-reason" style="padding: 4px;">
                            <option value=""></option>
                            <option value="vandalism">Vandalism</option>
                            <option value="spam">Spam</option>
                            <option value="test">Test rollback</option>
                            <option value="teste">Test edit</option>
                            <option value="rules-violation">Violation</option>
                            <option value="other">Other</option>
                        </select>
                        <input type="text" id="custom-reason" placeholder="Enter custom reason" style="display:none; padding: 4px; margin-top: 10px; width: 100%;">
                    </div>
                    
                    <div id="actions-section" style="text-align: center;">
                        <button id="rollback-selected" style="padding: 5px 10px; background-color: #007bff; color: #fff; border: none; border-radius: 3px; cursor: pointer; margin-right: 5px;">Rollback selected</button>
                        <button id="rollback-all" style="padding: 5px 10px; background-color: #ff4136; color: #fff; border: none; border-radius: 3px; cursor: pointer;">Rollback all</button>
                    </div>
                </div>
                
                <div id="filtered-edits-container" style="display:none; border: 1px solid #ccc; padding: 15px; margin-bottom:20px; border-radius: 4px; background: #f9f9f9;">
                    <h4>Filtered edits</h4>
                    <div style="margin-bottom: 10px;">
                        <input type="checkbox" id="select-all"> <label for="select-all">Select All</label>
                    </div>
                    <table id="filtered-edits-table" style="width: 100%; border-collapse: collapse;">
                        <thead>
                            <tr style="background: #e9e9e9;">
                                <th style="padding: 5px; border: 1px solid #ccc;">Revid</th>
                                <th style="padding: 5px; border: 1px solid #ccc;">Title</th>
                                <th style="padding: 5px; border: 1px solid #ccc;">Timestamp</th>
                                <th style="padding: 5px; border: 1px solid #ccc;">Namespace</th>
                                <th style="padding: 5px; border: 1px solid #ccc;">Size</th>
                                <th style="padding: 5px; border: 1px solid #ccc;">Select</th>
                            </tr>
                        </thead>
                        <tbody>
                            <!-- Wstawiane dynamicznie -->
                        </tbody>
                    </table>
                    <div style="text-align: center; margin-top: 10px;">
                        <button id="rollback-selected-filtered" style="padding: 5px 10px; background-color: #007bff; color: #fff; border: none; border-radius: 3px; cursor: pointer;">Rollback selected filtered edits</button>
                    </div>
                </div>
            `);
            
            const $toggleButton = $(`
                <button id="mass-rollback-toggle" style="padding: 5px 10px; margin-bottom: 10px; background-color: #007bff; color: #fff; border: none; border-radius: 3px; cursor: pointer;">
                    Show/Hide MassRollback
                </button>
            `);
            
            $toggleButton.on('click', function() {
                $('#mass-rollback-container').slideToggle();
            });
            
            $('#mw-content-text').prepend($toggleButton).prepend($container);
        },

        bindEvents: function() {
            $('#rollback-reason').on('change', function() {
                $('#custom-reason').toggle($(this).val() === 'other');
            });

            $('#clear-filters').on('click', function() {
                $('#start-date').val('');
                $('#end-date').val('');
                $('#namespace-filter').val(['all']);
                $('#size-filter').val('');
                $('#sort-order').val('desc');
            });

            $('#refresh-stats').on('click', this.fetchUserStats.bind(this));
            $('#show-filtered').on('click', this.displayFilteredEdits.bind(this));
            $('#rollback-selected-filtered').on('click', this.rollbackSelectedFromFiltered.bind(this));
            $('#rollback-selected').on('click', this.rollbackSelected.bind(this));
            $('#rollback-all').on('click', this.rollbackAll.bind(this));

            $(document).on('change', '#select-all', function() {
                const isChecked = $(this).is(':checked');
                $('#filtered-edits-table tbody input[type="checkbox"]').prop('checked', isChecked);
            });

            // Dodaj checkbox przy edycjach w widoku kontrybucji
            $('li[data-mw-revid]').each(function() {
                const $li = $(this);
                const revid = $li.data('mw-revid');
                const parentid = $li.data('mw-prev-revid');
                const title = $li.find('.mw-contributions-title').text().trim();
                const $checkbox = $('<input>', {
                    type: 'checkbox',
                    class: 'rollback-checkbox',
                    'data-revid': revid,
                    'data-parentid': parentid,
                    'data-title': title,
                    style: 'margin-right: 5px;'
                });
                $li.prepend($checkbox);
            });
        },

        fetchUserStats: function() {
            const userName = mw.config.get('wgRelevantUserName');
            const api = new mw.Api();
            api.get({
                action: 'query',
                list: 'usercontribs',
                ucuser: userName,
                uclimit: 'max',
                ucprop: 'timestamp'
            }).done(function(data) {
                const contributions = data.query.usercontribs;
                $('#total-edits').text(contributions.length);
                if (contributions.length > 0) {
                    const timestamps = contributions.map(c => new Date(c.timestamp)).sort((a, b) => a - b);
                    $('#first-edit').text(timestamps[0].toLocaleDateString());
                    $('#last-edit').text(timestamps[timestamps.length - 1].toLocaleDateString());
                }
            });
        },

        getNamespaceName: function(ns) {
            const nsMapping = {
                0: 'Main',
                1: 'Talk',
                2: 'User',
                3: 'User talk',
                4: 'Project',
                5: 'Project talk',
                6: 'File',
                7: 'File talk',
                8: 'MediaWiki',
                9: 'MediaWiki talk',
                10: 'Template',
                11: 'Template talk',
                12: 'Help',
                13: 'Help talk',
                14: 'Category',
                15: 'Category talk',
                100: 'Portal',
                101: 'Portal talk'
            };
            return nsMapping[ns] || ns;
        },

        applyFilters: function(contributions) {
            const startDate = $('#start-date').val() ? new Date($('#start-date').val()) : null;
            const endDate = $('#end-date').val() ? new Date($('#end-date').val()) : null;
            let namespaceFilter = $('#namespace-filter').val() || [];
            const sizeFilter = $('#size-filter').val();
            const sortOrder = $('#sort-order').val();

            if (namespaceFilter.includes('all')) {
                namespaceFilter = [];
            }

            let filtered = contributions.filter(contrib => {
                const contribDate = new Date(contrib.timestamp);
                if (startDate && contribDate < startDate) return false;
                if (endDate && contribDate > endDate) return false;
                if (namespaceFilter.length > 0 && !namespaceFilter.includes(contrib.ns.toString())) return false;
                if (sizeFilter) {
                    const size = contrib.size || 0;
                    switch (sizeFilter) {
                        case 'small': return size < 50;
                        case 'medium': return size >= 50 && size <= 500;
                        case 'large': return size > 500;
                    }
                }
                return true;
            });

            filtered.sort((a, b) => {
                return sortOrder === 'asc'
                    ? new Date(a.timestamp) - new Date(b.timestamp)
                    : new Date(b.timestamp) - new Date(a.timestamp);
            });

            return filtered;
        },

        displayFilteredEdits: function() {
            const userName = mw.config.get('wgRelevantUserName');
            const api = new mw.Api();
            api.get({
                action: 'query',
                list: 'usercontribs',
                ucuser: userName,
                uclimit: 'max',
                ucprop: 'ids|title|timestamp|size|ns|parentid'
            }).done((data) => {
                const allContributions = data.query.usercontribs;
                const filtered = this.applyFilters(allContributions);
                const $tbody = $('#filtered-edits-table tbody');
                $tbody.empty();

                if (filtered.length === 0) {
                    $tbody.append('<tr><td colspan="6" style="text-align:center; padding:5px;">No edits match the selected filters.</td></tr>');
                } else {
                    filtered.forEach(contrib => {
                        const row = `
                            <tr>
                                <td style="padding: 5px; border: 1px solid #ccc;">${contrib.revid}</td>
                                <td style="padding: 5px; border: 1px solid #ccc;">${contrib.title}</td>
                                <td style="padding: 5px; border: 1px solid #ccc;">${new Date(contrib.timestamp).toLocaleString()}</td>
                                <td style="padding: 5px; border: 1px solid #ccc;">${this.getNamespaceName(contrib.ns)}</td>
                                <td style="padding: 5px; border: 1px solid #ccc;">${contrib.size || 0}</td>
                                <td style="padding: 5px; border: 1px solid #ccc; text-align: center;">
                                    <input type="checkbox" class="filtered-rollback-checkbox" data-revid="${contrib.revid}" data-parentid="${contrib.parentid}" data-title="${contrib.title}">
                                </td>
                            </tr>
                        `;
                        $tbody.append(row);
                    });
                }
                $('#filtered-edits-container').css("display", "block").slideDown();
            });
        },

        confirmAction: function(message, count) {
            return new Promise((resolve) => {
                const $modal = $(`
                    <div style="position: fixed; top: 0; left: 0; width: 100%; height: 100%;
                         background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 9999;">
                        <div style="background: #fff; padding: 20px; border-radius: 4px; max-width: 400px; width: 90%;">
                            <h4 style="margin-top:0;">Confirm Rollback</h4>
                            <p>${message}</p>
                            <p>Number of edits: ${count}</p>
                            <div style="text-align: right; margin-top: 15px;">
                                <button id="confirm-action" style="padding: 5px 10px; background-color: #007bff; color: #fff; border: none; border-radius: 3px; cursor: pointer; margin-right: 5px;">Confirm</button>
                                <button id="cancel-action" style="padding: 5px 10px; background-color: #aaa; color: #fff; border: none; border-radius: 3px; cursor: pointer;">Cancel</button>
                            </div>
                        </div>
                    </div>
                `);
                $('body').append($modal);
                $modal.find('#confirm-action').on('click', function() {
                    $modal.remove();
                    resolve(true);
                });
                $modal.find('#cancel-action').on('click', function() {
                    $modal.remove();
                    resolve(false);
                });
            });
        },

        // Nowa wersja: grupujemy edycje wg tytułu i dla każdej grupy wykrywamy ciąg kolejnych edycji.
        performRollback: function(contributions) {
            const userName = mw.config.get('wgRelevantUserName');
            const reason = $('#rollback-reason').val() === 'other' 
                ? $('#custom-reason').val() 
                : $('#rollback-reason option:selected').text();
            const summary = `Reverted edit(s) by [[User:${userName}|${userName}]]: ${reason}`;
            const api = new mw.Api();

            // Grupujemy edycje wg tytułu strony
            const groups = {};
            contributions.forEach(contrib => {
                if (!groups[contrib.title]) {
                    groups[contrib.title] = [];
                }
                groups[contrib.title].push(contrib);
            });

            const promises = [];
            // Dla każdej strony – sortuj wg daty malejąco i wykryj ciąg edycji
            for (let title in groups) {
                let group = groups[title].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
                let latest = group[0];
                // Rozpoczynamy ciąg od najnowszej edycji
                let chain = [latest];
                let currentParent = latest.parentid;
                // Przeglądamy kolejne edycje dla tego samego tytułu
                for (let i = 1; i < group.length; i++) {
                    const contrib = group[i];
                    // Jeżeli kolejna edycja jest dokładnie poprzednikiem poprzednio znalezionego elementu, dodajemy do ciągu
                    if (parseInt(contrib.revid, 10) === parseInt(currentParent, 10)) {
                        chain.push(contrib);
                        currentParent = contrib.parentid;
                    } else {
                        break;
                    }
                }
                // Wykonaj rollback tylko, jeśli mamy przynajmniej jedną edycję (zawsze prawda)
                promises.push(api.postWithToken('csrf', {
                    action: 'edit',
                    undo: latest.revid,
                    undoafter: currentParent,
                    title: title,
                    summary: summary
                }));
            }
            return Promise.all(promises);
        },

        rollbackSelected: async function() {
            // Pobieramy zaznaczone edycje z listy kontrybucji
            const selected = $('.rollback-checkbox:checked').map(function() {
                return {
                    title: $(this).data('title'),
                    revid: $(this).data('revid'),
                    parentid: $(this).data('parentid'),
                    // Wartość timestamp może nie być dostępna w tym widoku – można opcjonalnie dodać dodatkowy pobór danych
                    timestamp: $(this).closest('li').find('.mw-contributions-timestamp').text() || new Date().toISOString()
                };
            }).get();

            if (selected.length === 0) {
                mw.notify('No edits selected.', { type: 'warn' });
                return;
            }
            const confirmed = await this.confirmAction('Are you sure you want to rollback the selected edits?', selected.length);
            if (!confirmed) return;
            try {
                await this.performRollback(selected);
                mw.notify(`Successfully rolled back ${selected.length} edits.`, { type: 'success' });
                location.reload();
            } catch (error) {
                mw.notify('Error during rollback.', { type: 'error' });
                console.error(error);
            }
        },

        rollbackSelectedFromFiltered: async function() {
            const selected = $('.filtered-rollback-checkbox:checked').map(function() {
                return {
                    title: $(this).data('title'),
                    revid: $(this).data('revid'),
                    parentid: $(this).data('parentid'),
                    timestamp: new Date().toISOString() // Jeśli API już zwraca timestamp, można go tutaj użyć
                };
            }).get();

            if (selected.length === 0) {
                mw.notify('No filtered edits selected.', { type: 'warn' });
                return;
            }
            const confirmed = await this.confirmAction('Are you sure you want to rollback the selected filtered edits?', selected.length);
            if (!confirmed) return;
            try {
                await this.performRollback(selected);
                mw.notify(`Successfully rolled back ${selected.length} filtered edits.`, { type: 'success' });
                location.reload();
            } catch (error) {
                mw.notify('Error during rollback.', { type: 'error' });
                console.error(error);
            }
        },

        rollbackAll: async function() {
            const userName = mw.config.get('wgRelevantUserName');
            const api = new mw.Api();
            try {
                // Dodajemy "timestamp" by móc grupować edycje
                const data = await api.get({
                    action: 'query',
                    list: 'usercontribs',
                    ucuser: userName,
                    uclimit: 'max',
                    ucprop: 'ids|title|parentid|timestamp'
                });
                const contributions = data.query.usercontribs;
                if (contributions.length === 0) {
                    mw.notify('No edits to rollback.', { type: 'warn' });
                    return;
                }
                const confirmed = await this.confirmAction('Are you sure you want to rollback ALL edits?', contributions.length);
                if (!confirmed) return;
                await this.performRollback(contributions);
                mw.notify(`Successfully rolled back all ${contributions.length} edits.`, { type: 'success' });
                location.reload();
            } catch (error) {
                mw.notify('Error during rollback.', { type: 'error' });
                console.error(error);
            }
        }
    };

    $(document).ready(function() {
        MassRollback.init();
    });
})(jQuery, mediaWiki);