User:BZPN/MassRollback2.js: Difference between revisions

From Test Wiki
Content deleted Content added
BZPN (talk | contribs)
Created page with "(function($, mw) { 'use strict'; const MassRollback = { init: function() { if (mw.config.get('wgCanonicalSpecialPageName') !== 'Contributions') { return; } this.createUI(); this.bindEvents(); this.fetchUserStats(); }, createUI: function() { // Główna zawartość, początkowo ukryta (display: none) const $container = $(`..."
 
BZPN (talk | contribs)
No edit summary
 
(12 intermediate revisions by the same user not shown)
Line 3: Line 3:


const MassRollback = {
const MassRollback = {

init: function() {
init: function() {
if (mw.config.get('wgCanonicalSpecialPageName') !== 'Contributions') {
const page = mw.config.get('wgCanonicalSpecialPageName');
if (page === 'Contributions') {
this.injectStyles();
this.createUI();
this.bindEvents();
this.fetchUserStats();
} else if (page === 'Recentchanges') {
this.injectStyles();
this.createRCUI();
this.bindRCEvents();
this.enhanceRecentChanges();
} else {
return;
return;
}
}
this.createUI();
},

this.bindEvents();
this.fetchUserStats();
injectStyles: function() {
const styles = `
/* Container as a clean drawer */
#mass-rollback-details {
border: 1px solid #c8ccd1;
border-radius: 2px;
width: 100%;
max-width: none; /* stretch across the content area */
background: #fff; /* white panel */
font-size: 14px;
font-family: sans-serif; /* match the example */
margin-bottom: 20px;
}
#mass-rollback-details summary {
padding: .6em 1em;
cursor: pointer;
font-weight: 600;
list-style: none;
display: flex;
align-items: center;
gap: .5em;
color: #202122;
}
#mass-rollback-details summary::-webkit-details-marker { display: none; }
#mass-rollback-details summary::before {
content: "";
display: inline-block;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid #202122;
transition: transform .2s ease;
margin-right: .2em;
}
#mass-rollback-details[open] summary::before { transform: rotate(180deg); }
#mass-rollback-container, #filtered-edits-container {
padding: 10px 16px 16px 16px;
}

/* Sections */
.mr-section h4 { margin: 0 0 .4em 0; font-weight: 600; }
.mr-section + .mr-section { margin-top: 14px; }
.mr-row { margin: .6em 0; display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center; }
.mr-row label { margin-right: 6px; color: #202122; }

/* Inputs */
.ooui-input, .ooui-select {
width: 100%;
max-width: 360px;
box-sizing: border-box;
border: 1px solid #a2a9b1;
border-radius: 2px;
padding: .45em .6em;
font-size: 14px;
background: #fff;
}
input[type="checkbox"] { margin-right: .4em; }

/* Buttons – keep color scheme */
.ooui-button {
display: inline-block;
padding: 6px 12px;
border: 1px solid #a2a9b1;
border-radius: 2px;
background: #f8f9fa;
cursor: pointer;
font-size: 14px;
line-height: 1.5;
}
.ooui-button:hover { background: #fff; border-color: #72777d; }
.ooui-button.primary { background: #36c; color: #fff; border-color: #36c; }
.ooui-button.primary:hover { background: #447ff5; border-color: #447ff5; }
.ooui-button.danger { background: #d33; color: #fff; border-color: #d33; }
.ooui-button.danger:hover { background: #c00; border-color: #c00; }

/* Table */
#filtered-edits-table { width:100%; border-collapse: collapse; background: #fff; }
#filtered-edits-table th, #filtered-edits-table td {
border: 1px solid #a2a9b1;
padding: 8px;
font-size: 13px;
}
#filtered-edits-table th { background: #eaecf0; text-align: left; }

/* Responsive table */
@media (max-width: 600px) {
#filtered-edits-table, #filtered-edits-table thead, #filtered-edits-table tbody, #filtered-edits-table th, #filtered-edits-table td, #filtered-edits-table tr {
display: block;
}
#filtered-edits-table tr { margin-bottom: 10px; }
#filtered-edits-table td { border: none; position: relative; padding-left: 50%; }
#filtered-edits-table td:before {
position: absolute;
top: 0; left: 0;
width: 45%; padding-left: 5px;
font-weight: bold;
white-space: nowrap;
}
#filtered-edits-table td:nth-of-type(1):before { content: "Revid"; }
#filtered-edits-table td:nth-of-type(2):before { content: "Title"; }
#filtered-edits-table td:nth-of-type(3):before { content: "Timestamp"; }
#filtered-edits-table td:nth-of-type(4):before { content: "Namespace"; }
#filtered-edits-table td:nth-of-type(5):before { content: "Size"; }
#filtered-edits-table td:nth-of-type(6):before { content: "Select"; }
}
`;
$('<style>').text(styles).appendTo('head');
},
},


createUI: function() {
createUI: function() {
// Główna zawartość, początkowo ukryta (display: none)
const $ui = $(`
const $container = $(`
<details id="mass-rollback-details" open>
<summary>Mass rollback</summary>
<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="mass-rollback-container">
<div id="stats-section" class="mr-section">
<div id="stats-section" style="margin-bottom: 15px;">
<h4>User statistics</h4>
<h4>User statistics</h4>
<p>Total edits: <strong id="total-edits">-</strong></p>
<p>Total edits: <strong id="total-edits">-</strong></p>
<p>First edit: <span id="first-edit">-</span></p>
<p>First edit: <span id="first-edit">-</span></p>
<p>Latest edit: <span id="last-edit">-</span>
<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>
<button id="refresh-stats" class="ooui-button">Refresh</button>
</div>
</p>
<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>
<div style="margin-bottom: 10px;">
<div id="filters-section" class="mr-section">
<label style="margin-right: 5px;">Namespace:</label>
<h4>Filter edits</h4>
<select id="namespace-filter" multiple style="padding: 4px; min-width: 150px;">
<div class="mr-row">
<option value="all">All</option>
<label>Date from:</label>
<option value="0">Main</option>
<input type="date" id="start-date" class="ooui-input">
<option value="1">Talk</option>
<label>to:</label>
<option value="2">User</option>
<input type="date" id="end-date" class="ooui-input">
<option value="3">User talk</option>
</div>
<option value="4">Project</option>
<div class="mr-row">
<option value="5">Project talk</option>
<label>Namespace:</label>
<option value="6">File</option>
<select id="namespace-filter" multiple class="ooui-select">
<option value="7">File talk</option>
<option value="all">All</option>
<option value="8">MediaWiki</option>
<option value="0">Main</option>
<option value="9">MediaWiki talk</option>
<option value="1">Talk</option>
<option value="10">Template</option>
<option value="2">User</option>
<option value="11">Template talk</option>
<option value="3">User talk</option>
<option value="12">Help</option>
<option value="4">Project</option>
<option value="13">Help talk</option>
<option value="5">Project talk</option>
<option value="14">Category</option>
<option value="6">File</option>
<option value="15">Category talk</option>
<option value="7">File talk</option>
<option value="100">Portal</option>
<option value="8">MediaWiki</option>
<option value="101">Portal talk</option>
<option value="9">MediaWiki talk</option>
</select>
<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 class="mr-row">
<label>Edit size:</label>
<select id="size-filter" class="ooui-select">
<option value="">Any</option>
<option value="small">Small (<50 bytes)</option>
<option value="medium">Medium (50-500 bytes)</option>
<option value="large">Large (>500 bytes)</option>
</select>
</div>
<div class="mr-row">
<label>Sort order:</label>
<select id="sort-order" class="ooui-select">
<option value="desc">Latest first</option>
<option value="asc">Oldest first</option>
</select>
</div>
<div class="mr-row">
<button id="clear-filters" class="ooui-button">Clear filters</button>
<button id="show-filtered" class="ooui-button primary">Show filtered edits</button>
</div>
</div>
</div>
<div style="margin-bottom: 10px;">
<div id="reason-section" class="mr-section">
<label style="margin-right: 5px;">Edit Size:</label>
<h4>Rollback reason</h4>
<select id="size-filter" style="padding: 4px;">
<select id="rollback-reason" class="ooui-select">
<option value="">Any</option>
<option value=""></option>
<option value="small">Small (&lt; 50 bytes)</option>
<option value="vandalism">Vandalism</option>
<option value="medium">Medium (50-500 bytes)</option>
<option value="spam">Adding spam links</option>
<option value="large">Large (&gt; 500 bytes)</option>
<option value="advert">Advertising or promotion</option>
<option value="unsourced">Unsourced or improperly cited material</option>
<option value="copyright">Copyright violation</option>
<option value="blanking">Removal of content, blanking</option>
<option value="wronginfo">Adding wrong information</option>
<option value="other">Other</option>
</select>
</select>
<input type="text" id="custom-reason" placeholder="Enter custom reason" class="ooui-input" style="display:none; width:100%; margin-top:6px;">
</div>
</div>
<div style="margin-bottom: 10px;">
<div id="actions-section" class="mr-section" style="text-align:center;">
<label style="margin-right: 5px;">Sort Order:</label>
<button id="rollback-selected" class="ooui-button primary">Rollback selected</button>
<select id="sort-order" style="padding: 4px;">
<button id="rollback-all" class="ooui-button danger">Rollback all</button>
<option value="desc">Latest first</option>
<option value="asc">Oldest first</option>
</select>
</div>
</div>
<div style="margin-bottom: 10px;">
<div id="filtered-edits-container" class="mr-section" style="display:none;">
<h4>Filtered edits</h4>
<button id="clear-filters" style="padding: 5px 10px; background-color: #aaa; color: #fff; border: none; border-radius: 3px; cursor: pointer;">Clear filters</button>
<div class="mr-row">
<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>
<input type="checkbox" id="select-all"> <label for="select-all">Select all</label>
</div>
<table id="filtered-edits-table">
<thead>
<tr>
<th>Revid</th>
<th>Title</th>
<th>Timestamp</th>
<th>Namespace</th>
<th>Size</th>
<th>Select</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div style="text-align:center; margin-top:10px;">
<button id="rollback-selected-filtered" class="ooui-button primary">Rollback selected filtered edits</button>
</div>
</div>
</div>
</div>
</div>
</details>
<hr style="margin: 15px 0;">
`);

<div id="reason-section" style="margin-bottom: 15px;">
// Put the drawer at the top of the content area
<h4>Rollback reason</h4>
$('#mw-content-text').prepend($ui);
},
<select id="rollback-reason" style="padding: 4px;">

<option value="">-- Select reason --</option>
// RC version: collapsed by default, without statistics section
<option value="vandalism">Vandalism</option>
createRCUI: function() {
<option value="spam">Spam</option>
<option value="test">Test rollback</option>
const $ui = $(`
<option value="teste">Test edit</option>
<details id="mass-rollback-details">
<option value="rules-violation">Violation</option>
<summary>Mass rollback</summary>
<option value="other">Other</option>
<div id="mass-rollback-container">
</select>
<div id="filters-section" class="mr-section">
<h4>Filter edits</h4>
<input type="text" id="custom-reason" placeholder="Enter custom reason" style="display:none; padding: 4px; margin-top: 10px; width: 100%;">
<div class="mr-row">
<label>Date from:</label>
<input type="date" id="start-date" class="ooui-input">
<label>to:</label>
<input type="date" id="end-date" class="ooui-input">
</div>
<div class="mr-row">
<label>Namespace:</label>
<select id="namespace-filter" multiple class="ooui-select">
<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 class="mr-row">
<label>Edit size:</label>
<select id="size-filter" class="ooui-select">
<option value="">Any</option>
<option value="small">Small (<50 bytes)</option>
<option value="medium">Medium (50-500 bytes)</option>
<option value="large">Large (>500 bytes)</option>
</select>
</div>
<div class="mr-row">
<label>Sort order:</label>
<select id="sort-order" class="ooui-select">
<option value="desc">Latest first</option>
<option value="asc">Oldest first</option>
</select>
</div>
<div class="mr-row">
<button id="clear-filters" class="ooui-button">Clear filters</button>
<button id="show-filtered" class="ooui-button primary">Show filtered edits</button>
</div>
</div>
<div id="reason-section" class="mr-section">
<h4>Rollback reason</h4>
<select id="rollback-reason" class="ooui-select">
<option value=""></option>
<option value="vandalism">Vandalism</option>
<option value="spam">Adding spam links</option>
<option value="advert">Advertising or promotion</option>
<option value="unsourced">Unsourced or improperly cited material</option>
<option value="copyright">Copyright violation</option>
<option value="blanking">Removal of content, blanking</option>
<option value="wronginfo">Adding wrong information</option>
<option value="other">Other</option>
</select>
<input type="text" id="custom-reason" placeholder="Enter custom reason" class="ooui-input" style="display:none; width:100%; margin-top:6px;">
</div>
</div>
</div>
</details>
<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>
`);
`);
// Insert after RC filters table if exists, else at top
// Przycisk przełączający widoczność narzędzia
const $anchor = $('#mw-rcfilters-ui-table');
const $toggleButton = $(`
if ($anchor.length) {
$anchor.after($ui);
<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 Mass Rollback Tool
} else {
</button>
$('#mw-content-text').prepend($ui);
`);
}
$toggleButton.on('click', function() {
$('#mass-rollback-container').slideToggle();
// Zmiana tekstu przycisku w zależności od stanu widoczności
const btn = $(this);
btn.text(btn.text() === 'Show Mass Rollback Tool' ? 'Hide Mass Rollback Tool' : 'Show Mass Rollback Tool');
});
// Dodaj przycisk i kontener do elementu #mw-content-text
$('#mw-content-text').prepend($toggleButton).prepend($container);
},
},


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

$('#clear-filters').on('click', function() {
$('#clear-filters').on('click', function() {
$('#start-date').val('');
$('#start-date').val('');
Line 161: Line 340:
$('#sort-order').val('desc');
$('#sort-order').val('desc');
});
});

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

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

$('li[data-mw-revid]').each(function() {
$('li[data-mw-revid]').each(function() {
const $li = $(this);
const $li = $(this);
Line 184: Line 360:
'data-parentid': parentid,
'data-parentid': parentid,
'data-title': title,
'data-title': title,
style: 'margin-right: 5px;'
style: 'margin-right:5px;'
});
});
$li.prepend($checkbox);
$li.prepend($checkbox);
Line 193: Line 369:
const userName = mw.config.get('wgRelevantUserName');
const userName = mw.config.get('wgRelevantUserName');
const api = new mw.Api();
const api = new mw.Api();

api.get({
// 1) Get accurate total edits from 'users' prop
const userPromise = api.get({
action: 'query',
list: 'users',
ususers: userName,
usprop: 'editcount'
});

// 2) Get first contribution (oldest)
const firstPromise = api.get({
action: 'query',
action: 'query',
list: 'usercontribs',
list: 'usercontribs',
ucuser: userName,
ucuser: userName,
uclimit: 'max',
uclimit: 1,
ucprop: 'timestamp'
ucprop: 'timestamp',
}).done(function(data) {
ucdir: 'newer'
const contributions = data.query.usercontribs;
});

$('#total-edits').text(contributions.length);
if (contributions.length > 0) {
// 3) Get last contribution (newest)
const timestamps = contributions.map(c => new Date(c.timestamp)).sort((a, b) => a - b);
const lastPromise = api.get({
$('#first-edit').text(timestamps[0].toLocaleDateString());
action: 'query',
list: 'usercontribs',
$('#last-edit').text(timestamps[timestamps.length - 1].toLocaleDateString());
}
ucuser: userName,
uclimit: 1,
ucprop: 'timestamp',
ucdir: 'older'
});

Promise.all([userPromise, firstPromise, lastPromise]).then(([userData, firstData, lastData]) => {
const user = (userData.query && userData.query.users && userData.query.users[0]) || {};
const editcount = typeof user.editcount === 'number' ? user.editcount : '-';
const first = (firstData.query && firstData.query.usercontribs && firstData.query.usercontribs[0]) || null;
const last = (lastData.query && lastData.query.usercontribs && lastData.query.usercontribs[0]) || null;

$('#total-edits').text(editcount);
$('#first-edit').text(first ? new Date(first.timestamp).toLocaleDateString() : '-');
$('#last-edit').text(last ? new Date(last.timestamp).toLocaleDateString() : '-');
}).catch(() => {
// Fallback to previous approach if anything fails
api.get({
action: 'query',
list: 'usercontribs',
ucuser: userName,
uclimit: '5000',
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());
} else {
$('#first-edit').text('-');
$('#last-edit').text('-');
}
});
});
});
},
},


getNamespaceName: function(ns) {
getNamespaceName: function(ns) {
const mapping = {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'};
const nsMapping = {
0: 'Main',
return mapping[ns] || ns;
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) {
applyFilters: function(contribs) {
const startDate = $('#start-date').val() ? new Date($('#start-date').val()) : null;
const start = $('#start-date').val() ? new Date($('#start-date').val()) : null;
const endDate = $('#end-date').val() ? new Date($('#end-date').val()) : null;
const end = $('#end-date').val() ? new Date($('#end-date').val()) : null;
let namespaceFilter = $('#namespace-filter').val() || [];
let nsFilter = $('#namespace-filter').val() || [];
const sizeFilter = $('#size-filter').val();
const sizeFilter = $('#size-filter').val();
const sortOrder = $('#sort-order').val();
const sortOrder = $('#sort-order').val();
if (nsFilter.includes('all')) nsFilter = [];

if (namespaceFilter.includes('all')) {
let filtered = contribs.filter(c => {
namespaceFilter = [];
const d = new Date(c.timestamp);
}
if (start && d < start) return false;
if (end && d > end) return false;

if (nsFilter.length && !nsFilter.includes(c.ns.toString())) return false;
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) {
if (sizeFilter) {
const size = contrib.size || 0;
const s = c.size || 0;
switch (sizeFilter) {
if (sizeFilter === 'small' && s >= 50) return false;
case 'small': return size < 50;
if (sizeFilter === 'medium' && (s < 50 || s > 500)) return false;
case 'medium': return size >= 50 && size <= 500;
if (sizeFilter === 'large' && s <= 500) return false;
case 'large': return size > 500;
}
}
}
return true;
return true;
});
});
filtered.sort((a,b) => sortOrder==='asc'? new Date(a.timestamp)-new Date(b.timestamp) : new Date(b.timestamp)-new Date(a.timestamp));

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;
return filtered;
},
},
Line 274: Line 463:
const api = new mw.Api();
const api = new mw.Api();
api.get({
api.get({
action: 'query',
action:'query',
list: 'usercontribs',
list:'usercontribs',
ucuser: userName,
ucuser:userName,
uclimit: 'max',
uclimit:'5000',
ucprop: 'ids|title|timestamp|size|ns|parentid'
ucprop:'ids|title|timestamp|size|ns|parentid'
}).done((data) => {
}).done((data) => {
const allContributions = data.query.usercontribs;
const filtered = this.applyFilters(data.query.usercontribs);
const filtered = this.applyFilters(allContributions);
const $tbody = $('#filtered-edits-table tbody').empty();
const $tbody = $('#filtered-edits-table tbody');
if (!filtered.length) {
$tbody.append('<tr><td colspan="6" style="text-align:center;">No edits match the selected filters.</td></tr>');
$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 {
} else {
filtered.forEach(contrib => {
filtered.forEach(c => {
const row = `
$tbody.append(`
<tr>
<tr>
<td style="padding: 5px; border: 1px solid #ccc;">${contrib.revid}</td>
<td>${c.revid}</td>
<td style="padding: 5px; border: 1px solid #ccc;">${contrib.title}</td>
<td>${c.title}</td>
<td style="padding: 5px; border: 1px solid #ccc;">${new Date(contrib.timestamp).toLocaleString()}</td>
<td>${new Date(c.timestamp).toLocaleString()}</td>
<td style="padding: 5px; border: 1px solid #ccc;">${this.getNamespaceName(contrib.ns)}</td>
<td>${this.getNamespaceName(c.ns)}</td>
<td style="padding: 5px; border: 1px solid #ccc;">${contrib.size || 0}</td>
<td>${c.size||0}</td>
<td style="padding: 5px; border: 1px solid #ccc; text-align: center;">
<td style="text-align:center;"><input type="checkbox" class="filtered-rollback-checkbox" data-revid="${c.revid}" data-parentid="${c.parentid}" data-title="${c.title}"></td>
</tr>`);
<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').style = "display:block;";
$('#filtered-edits-container').slideDown();
$('#filtered-edits-container').slideDown();
});
});
},

// RC-only bindings
bindRCEvents: function() {
$('#rollback-reason').on('change', function() {
$('#custom-reason').toggle($(this).val() === 'other');
});
},

// Enhance RC entries by adding a red Rollback button after the thanks link
enhanceRecentChanges: function() {
const self = this;
const $rcRoot = $('#mw-content-text');
$rcRoot.find('.mw-changeslist .mw-changeslist-line, .mw-changeslist li').each(function() {
const $line = $(this);
if ($line.data('mr-enhanced')) return;
$line.data('mr-enhanced', true);

const $anchor = $line.find('.mw-thanks-thank-link').last();
const $btn = $('<button class="ooui-button danger rc-rollback-btn" style="margin-left:8px;">Rollback</button>');
if ($anchor.length) $anchor.after($btn); else $line.append($btn);

const parsed = self._extractRCEdit($line);
if (!parsed) {
$btn.prop('disabled', true).attr('title', 'Unable to get revid/parentid');
return;
}
$btn.data('mr-edit', parsed);

$btn.on('click', async function() {
const confirmResult = await self.confirmActionWithReason('Rollback this edit from Recent changes?', 1);
if (!confirmResult || !confirmResult.confirm) return;

if (confirmResult.reasonValue) {
$('#rollback-reason').val(confirmResult.reasonValue).trigger('change');
}
if (confirmResult.reasonValue === 'other') {
$('#custom-reason').val(confirmResult.customReason || '');
}

try {
const edit = $(this).data('mr-edit');
// If username missing from config, try from RC line extraction
if (!mw.config.get('wgRelevantUserName') && edit.user) {
mw.config.set('wgRelevantUserName', edit.user);
}
await self.performRollback([edit]);
mw.notify('Successfully rolled back 1 edit.', { type: 'success' });
if (confirmResult.leaveWarning) {
await self.leaveUserWarning(confirmResult.warningType, edit.user || null);
}
if (confirmResult.reportVIP) {
await self.performVIPReport(confirmResult.vip);
}
} catch (e) {
mw.notify('Error during rollback.', { type: 'error' });
console.error(e);
}
});
});
},

// Try to extract edit metadata (title, revid, parentid) from an RC line
_extractRCEdit: function($line) {
const revid = $line.data('mw-revid') || $line.attr('data-mw-revid');
const parentid = $line.data('mw-rollback-revision') || $line.attr('data-mw-prev-revid') || $line.attr('data-mw-rollback-revision');
// Try to get page title
let title = $line.find('.mw-title, .mw-changeslist-title, a.mw-changeslist-title').first().text().trim();
if (!title) {
const $plink = $line.find('a[title]').first();
title = ($plink.attr('title') || '').trim();
}
// Try to get username (author of the edit) from the RC line
let user = '';
// Prefer the direct user page link, not talk/contribs
const $userAnchor = $line.find('a.mw-userlink[href*="/wiki/User:"]:not([href*="User_talk"]):not([href*="Special:Contributions"])').first();
if ($userAnchor.length) {
user = ($userAnchor.find('bdi').text() || $userAnchor.text() || '').trim();
}
// Fallback: from talk link href
if (!user) {
const $talk = $line.find('a[href*="/wiki/User_talk:"]').first();
if ($talk.length) {
const href = $talk.attr('href');
const m = href && href.match(/User_talk:([^?#]+)/);
if (m) user = decodeURIComponent(m[1]);
}
}
// Fallback: from Special:Contributions link href
if (!user) {
const $contribs = $line.find('a[href*="Special:Contributions/"]').first();
if ($contribs.length) {
const href = $contribs.attr('href');
const m = href && href.match(/Special:Contributions\/([^?#]+)/);
if (m) user = decodeURIComponent(m[1]);
}
}
user = user || null;
if (revid && parentid && title) {
return { title: title, revid: revid, parentid: parentid, timestamp: new Date().toISOString(), user: user || null };
}
// Fallback from diff link
const $diff = $line.find('a[href*="diff="][href*="oldid="]').first();
if ($diff.length) {
const params = this._parseParams($diff.attr('href'));
const rv = params.diff || params.curid || params.oldid;
const pid = params.oldid || params.prev || '';
if (!title) title = ($diff.attr('title') || '').replace(/^Special:Diff\/\d+/, '').trim();
if (rv && title) return { title: title, revid: rv, parentid: pid, timestamp: new Date().toISOString(), user: user || null };
}
return null;
},

_parseParams: function(href) {
try {
const u = href.indexOf('http') === 0 ? new URL(href) : new URL(href, location.origin);
const o = {};
for (const [k, v] of u.searchParams.entries()) o[k] = v;
return o;
} catch (e) { return {}; }
},
},


confirmAction: function(message, count) {
confirmAction: function(message, count) {
return new Promise((resolve) => {
return new Promise(resolve => {
const $modal = $(`
const $modal = $(`
<div style="position: fixed; top: 0; left: 0; width: 100%; height: 100%;
<div style="position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:9999;">
background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 9999;">
<div style="background:#fff;padding:16px 16px 12px 16px;border-radius:4px;max-width:620px;width:92%;box-shadow:0 2px 12px rgba(0,0,0,0.2);font-family:sans-serif;">
<div style="background: #fff; padding: 20px; border-radius: 4px; max-width: 400px; width: 90%;">
<h4 style="margin:0 0 .4em 0;">Confirm rollback</h4>
<h4 style="margin-top:0;">Confirm Rollback</h4>
<p style="margin:.2em 0;">${message}</p>
<p>${message}</p>
<p style="margin:.2em 0; color:#54595d;">Number of edits: <strong>${count}</strong></p>
<p>Number of edits: ${count}</p>
<div style="margin-top:.6em; padding:.6em; border:1px solid #a2a9b1; border-radius:2px; background:#f8f9fa;">
<div style="text-align: right; margin-top: 15px;">
<label style="display:flex; align-items:center; gap:.5em;">
<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>
<input type="checkbox" id="leave-warning-checkbox"> Leave a message on user's talk page
</label>
<button id="cancel-action" style="padding: 5px 10px; background-color: #aaa; color: #fff; border: none; border-radius: 3px; cursor: pointer;">Cancel</button>
<div id="warning-options" style="display:none; margin-top:.6em;">
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
<label>Warning level:</label>
<select id="warning-level" class="ooui-select">
<option value="level1">Level 1</option>
<option value="level2">Level 2</option>
<option value="level3">Level 3</option>
<option value="level4">Level 4</option>
<option value="level4im">Only warning</option>
</select>
<label>Warning type:</label>
<select id="warning-type" class="ooui-select"></select>
</div>
</div>
</div>
<div style="margin-top:.8em; padding:.6em; border:1px solid #a2a9b1; border-radius:2px; background:#f8f9fa;">
<label style="display:flex; align-items:center; gap:.5em;">
<input type="checkbox" id="leave-vip-checkbox"> Report at Wikipedia:Vandalism in progress (User-reported)
</label>
<div id="vip-options" style="display:none; margin-top:.6em;">
<div class="mr-row" style="margin:0 0 .6em 0;">
<label style="min-width:180px;">Primary linked page:</label>
<input type="text" id="vip-page" class="ooui-input" placeholder="Optional: Page title e.g. Article name">
</div>
<div class="mr-row" style="margin:0 0 .6em 0;">
<label style="min-width:180px;">Revision ID when vandalised:</label>
<input type="text" id="vip-badid" class="ooui-input" placeholder="Optional" disabled>
</div>
<div class="mr-row" style="margin:0 0 .6em 0;">
<label style="min-width:180px;">Last good revision ID:</label>
<input type="text" id="vip-goodid" class="ooui-input" placeholder="Optional" disabled>
</div>
<div class="mr-row" style="margin:0 0 .6em 0; align-items:flex-start;">
<label style="min-width:180px;">Reasons:</label>
<div>
<label style="display:block;"><input type="checkbox" class="vip-type" value="final"> Vandalism after final (level 4 or 4im) warning given</label>
<label style="display:block;"><input type="checkbox" class="vip-type" value="postblock"> Vandalism after recent (within 1 day) release of block</label>
<label style="display:block;"><input type="checkbox" class="vip-type" value="vandalonly"> Evidently a vandalism-only account</label>
<label style="display:block;"><input type="checkbox" class="vip-type" value="spambot"> Account is evidently a spambot or a compromised account</label>
<label style="display:block;"><input type="checkbox" class="vip-type" value="promoonly"> Account is a promotion-only account</label>
</div>
</div>
<div class="mr-row" style="margin:0 0 .4em 0;">
<label style="min-width:180px;">Comment:</label>
<textarea id="vip-comment" class="ooui-input" style="height:70px; max-width:100%; width:100%;"></textarea>
</div>
</div>
</div>
<div style="text-align:right; margin-top:.8em; display:flex; gap:8px; justify-content:flex-end;">
<button id="cancel-action" class="ooui-button">Cancel</button>
<button id="confirm-action" class="ooui-button primary">Confirm</button>
</div>
</div>
</div>
</div>
</div>
</div>`);

`);
const warningMap = {
level1: {
"uw-vandalism1": { label: "Vandalism" },
"uw-test1": { label: "Editing tests" },
"uw-delete1": { label: "Removal of content, blanking" },
"uw-advert1": { label: "Using Wikipedia for advertising or promotion" },
"uw-spam1": { label: "Adding spam links" },
"uw-copyright1": { label: "Copyright violation" },
"uw-unsourced1": { label: "Addition of unsourced or improperly cited material" }
},
level2: {
"uw-vandalism2": { label: "Vandalism" },
"uw-test2": { label: "Editing tests" },
"uw-delete2": { label: "Removal of content, blanking" },
"uw-advert2": { label: "Using Wikipedia for advertising or promotion" },
"uw-spam2": { label: "Adding spam links" },
"uw-copyright2": { label: "Copyright violation" },
"uw-unsourced2": { label: "Addition of unsourced or improperly cited material" }
},
level3: {
"uw-vandalism3": { label: "Vandalism" },
"uw-test3": { label: "Editing tests" },
"uw-delete3": { label: "Removal of content, blanking" },
"uw-advert3": { label: "Using Wikipedia for advertising or promotion" },
"uw-spam3": { label: "Adding spam links" },
"uw-error3": { label: "Deliberately adding wrong information" },
"uw-unsourced3": { label: "Addition of unsourced or improperly cited material" }
},
level4: {
"uw-vandalism4": { label: "Vandalism" },
"uw-test4": { label: "Editing tests" },
"uw-delete4": { label: "Removal of content, blanking" },
"uw-advert4": { label: "Using Wikipedia for advertising or promotion" },
"uw-spam4": { label: "Adding spam links" },
"uw-error4": { label: "Deliberately adding wrong information" },
"uw-unsourced4": { label: "Addition of unsourced or improperly cited material" }
},
level4im: {
"uw-vandalism4im": { label: "Vandalism" },
"uw-delete4im": { label: "Removal of content, blanking" },
"uw-spam4im": { label: "Adding spam links" },
"uw-biog4im": { label: "BLP violations" },
"uw-move4im": { label: "Page moves against conventions" },
"uw-npa4im": { label: "Personal attack" }
}
};

function populateTypes(level) {
const $type = $modal.find('#warning-type');
$type.empty();
const group = warningMap[level] || {};
Object.keys(group).forEach(key => {
$type.append(`<option value="${key}">${group[key].label}</option>`);
});
}

$('body').append($modal);
$('body').append($modal);
$modal.find('#confirm-action').on('click', function() {
populateTypes('level1');
$modal.find('#warning-level').on('change', function(){ populateTypes(this.value); });
$modal.find('#leave-warning-checkbox').on('change', function(){
$modal.find('#warning-options').toggle(this.checked);
});

// VIP UI logic
$modal.find('#leave-vip-checkbox').on('change', function(){
$modal.find('#vip-options').toggle(this.checked);
});
const $vipPage = $modal.find('#vip-page');
const $vipBad = $modal.find('#vip-badid');
const $vipGood = $modal.find('#vip-goodid');
$vipPage.on('input', function(){
const has = this.value.trim() !== '';
$vipBad.prop('disabled', !has);
$vipGood.prop('disabled', !has || $vipBad.val().trim() !== '');
});
$vipBad.on('input', function(){
$vipGood.prop('disabled', this.value.trim() === '');
});

$modal.find('#confirm-action').on('click', function(){
const leave = $modal.find('#leave-warning-checkbox').is(':checked');
const level = $modal.find('#warning-level').val();
const type = $modal.find('#warning-type').val();
const reportVIP = $modal.find('#leave-vip-checkbox').is(':checked');
const vip = {
page: $vipPage.val().trim(),
badid: $vipBad.val().trim(),
goodid: $vipGood.val().trim(),
types: $modal.find('.vip-type:checked').map(function(){return this.value;}).get(),
comment: $modal.find('#vip-comment').val().trim()
};
$modal.remove();
$modal.remove();
resolve(true);
resolve({ confirm: true, leaveWarning: leave, warningLevel: level, warningType: type, reportVIP, vip });
});
});
$modal.find('#cancel-action').on('click', function() {
$modal.find('#cancel-action').on('click', function(){ $modal.remove(); resolve({ confirm: false }); });
});
},

// Variant used from RC: confirmation dialog with inline reason controls
confirmActionWithReason: 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.45);display:flex;align-items:center;justify-content:center;z-index:9999;">
<div style="background:#fff;padding:16px 16px 12px 16px;border-radius:4px;max-width:640px;width:92%;box-shadow:0 2px 12px rgba(0,0,0,0.2);font-family:sans-serif;">
<h4 style="margin:0 0 .4em 0;">Confirm rollback</h4>
<p style="margin:.2em 0;">${message}</p>
<p style="margin:.2em 0; color:#54595d;">Number of edits: <strong>${count}</strong></p>
<div style="margin-top:.6em; padding:.6em; border:1px solid #a2a9b1; border-radius:2px; background:#f8f9fa;">
<label style="display:block; font-weight:600; margin-bottom:.4em;">Rollback reason</label>
<div class="mr-row" style="margin:0 0 .4em 0;">
<select id="modal-rollback-reason" class="ooui-select" style="max-width:100%;">
<option value=""></option>
<option value="vandalism">Vandalism</option>
<option value="spam">Adding spam links</option>
<option value="advert">Advertising or promotion</option>
<option value="unsourced">Unsourced or improperly cited material</option>
<option value="copyright">Copyright violation</option>
<option value="blanking">Removal of content, blanking</option>
<option value="wronginfo">Adding wrong information</option>
<option value="other">Other</option>
</select>
</div>
<input type="text" id="modal-custom-reason" class="ooui-input" placeholder="Enter custom reason" style="display:none; width:100%;">
</div>
<div style="margin-top:.6em; padding:.6em; border:1px solid #a2a9b1; border-radius:2px; background:#f8f9fa;">
<label style="display:flex; align-items:center; gap:.5em;">
<input type="checkbox" id="leave-warning-checkbox"> Leave a message on user's talk page
</label>
<div id="warning-options" style="display:none; margin-top:.6em;">
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
<label>Warning level:</label>
<select id="warning-level" class="ooui-select">
<option value="level1">Level 1</option>
<option value="level2">Level 2</option>
<option value="level3">Level 3</option>
<option value="level4">Level 4</option>
<option value="level4im">Only warning</option>
</select>
<label>Warning type:</label>
<select id="warning-type" class="ooui-select"></select>
</div>
</div>
</div>
<div style="margin-top:.8em; padding:.6em; border:1px solid #a2a9b1; border-radius:2px; background:#f8f9fa;">
<label style="display:flex; align-items:center; gap:.5em;">
<input type="checkbox" id="leave-vip-checkbox"> Report at Wikipedia:Vandalism in progress (User-reported)
</label>
<div id="vip-options" style="display:none; margin-top:.6em;">
<div class="mr-row" style="margin:0 0 .6em 0;">
<label style="min-width:180px;">Primary linked page:</label>
<input type="text" id="vip-page" class="ooui-input" placeholder="Optional: Page title e.g. Article name">
</div>
<div class="mr-row" style="margin:0 0 .6em 0;">
<label style="min-width:180px;">Revision ID when vandalised:</label>
<input type="text" id="vip-badid" class="ooui-input" placeholder="Optional" disabled>
</div>
<div class="mr-row" style="margin:0 0 .6em 0;">
<label style="min-width:180px;">Last good revision ID:</label>
<input type="text" id="vip-goodid" class="ooui-input" placeholder="Optional" disabled>
</div>
<div class="mr-row" style="margin:0 0 .6em 0; align-items:flex-start;">
<label style="min-width:180px;">Reasons:</label>
<div>
<label style="display:block;"><input type="checkbox" class="vip-type" value="final"> Vandalism after final (level 4 or 4im) warning given</label>
<label style="display:block;"><input type="checkbox" class="vip-type" value="postblock"> Vandalism after recent (within 1 day) release of block</label>
<label style="display:block;"><input type="checkbox" class="vip-type" value="vandalonly"> Evidently a vandalism-only account</label>
<label style="display:block;"><input type="checkbox" class="vip-type" value="spambot"> Account is evidently a spambot or a compromised account</label>
<label style="display:block;"><input type="checkbox" class="vip-type" value="promoonly"> Account is a promotion-only account</label>
</div>
</div>
<div class="mr-row" style="margin:0 0 .4em 0;">
<label style="min-width:180px;">Comment:</label>
<textarea id="vip-comment" class="ooui-input" style="height:70px; max-width:100%; width:100%;"></textarea>
</div>
</div>
</div>
<div style="text-align:right; margin-top:.8em; display:flex; gap:8px; justify-content:flex-end;">
<button id="cancel-action" class="ooui-button">Cancel</button>
<button id="confirm-action" class="ooui-button primary">Confirm</button>
</div>
</div>
</div>`);

const warningMap = {
level1: {
"uw-vandalism1": { label: "Vandalism" },
"uw-test1": { label: "Editing tests" },
"uw-delete1": { label: "Removal of content, blanking" },
"uw-advert1": { label: "Using Wikipedia for advertising or promotion" },
"uw-spam1": { label: "Adding spam links" },
"uw-copyright1": { label: "Copyright violation" },
"uw-unsourced1": { label: "Addition of unsourced or improperly cited material" }
},
level2: {
"uw-vandalism2": { label: "Vandalism" },
"uw-test2": { label: "Editing tests" },
"uw-delete2": { label: "Removal of content, blanking" },
"uw-advert2": { label: "Using Wikipedia for advertising or promotion" },
"uw-spam2": { label: "Adding spam links" },
"uw-copyright2": { label: "Copyright violation" },
"uw-unsourced2": { label: "Addition of unsourced or improperly cited material" }
},
level3: {
"uw-vandalism3": { label: "Vandalism" },
"uw-test3": { label: "Editing tests" },
"uw-delete3": { label: "Removal of content, blanking" },
"uw-advert3": { label: "Using Wikipedia for advertising or promotion" },
"uw-spam3": { label: "Adding spam links" },
"uw-error3": { label: "Deliberately adding wrong information" },
"uw-unsourced3": { label: "Addition of unsourced or improperly cited material" }
},
level4: {
"uw-vandalism4": { label: "Vandalism" },
"uw-test4": { label: "Editing tests" },
"uw-delete4": { label: "Removal of content, blanking" },
"uw-advert4": { label: "Using Wikipedia for advertising or promotion" },
"uw-spam4": { label: "Adding spam links" },
"uw-error4": { label: "Deliberately adding wrong information" },
"uw-unsourced4": { label: "Addition of unsourced or improperly cited material" }
},
level4im: {
"uw-vandalism4im": { label: "Vandalism" },
"uw-delete4im": { label: "Removal of content, blanking" },
"uw-spam4im": { label: "Adding spam links" },
"uw-biog4im": { label: "BLP violations" },
"uw-move4im": { label: "Page moves against conventions" },
"uw-npa4im": { label: "Personal attack" }
}
};
function populateTypes(level) {
const $type = $modal.find('#warning-type');
$type.empty();
const group = warningMap[level] || {};
Object.keys(group).forEach(key => {
$type.append(`<option value="${key}">${group[key].label}</option>`);
});
}

$('body').append($modal);
populateTypes('level1');
$modal.find('#modal-rollback-reason').on('change', function(){
$modal.find('#modal-custom-reason').toggle(this.value === 'other');
});
$modal.find('#warning-level').on('change', function(){ populateTypes(this.value); });
$modal.find('#leave-warning-checkbox').on('change', function(){
$modal.find('#warning-options').toggle(this.checked);
});
$modal.find('#leave-vip-checkbox').on('change', function(){
$modal.find('#vip-options').toggle(this.checked);
});
const $vipPage = $modal.find('#vip-page');
const $vipBad = $modal.find('#vip-badid');
const $vipGood = $modal.find('#vip-goodid');
$vipPage.on('input', function(){
const has = this.value.trim() !== '';
$vipBad.prop('disabled', !has);
$vipGood.prop('disabled', !has || $vipBad.val().trim() !== '');
});
$vipBad.on('input', function(){
$vipGood.prop('disabled', this.value.trim() === '');
});

$modal.find('#confirm-action').on('click', function(){
const reasonValue = $modal.find('#modal-rollback-reason').val();
const customReason = $modal.find('#modal-custom-reason').val().trim();
const leave = $modal.find('#leave-warning-checkbox').is(':checked');
const level = $modal.find('#warning-level').val();
const type = $modal.find('#warning-type').val();
const reportVIP = $modal.find('#leave-vip-checkbox').is(':checked');
const vip = {
page: $vipPage.val().trim(),
badid: $vipBad.val().trim(),
goodid: $vipGood.val().trim(),
types: $modal.find('.vip-type:checked').map(function(){return this.value;}).get(),
comment: $modal.find('#vip-comment').val().trim()
};
$modal.remove();
$modal.remove();
resolve({ confirm: true, reasonValue, customReason, leaveWarning: leave, warningLevel: level, warningType: type, reportVIP, vip });
resolve(false);
});
});
$modal.find('#cancel-action').on('click', function(){ $modal.remove(); resolve({ confirm: false }); });
});
});
},
},


// Nowa wersja: grupujemy edycje wg tytułu i dla każdej grupy wykrywamy ciąg kolejnych edycji.
performRollback: function(contributions) {
performRollback: function(contributions) {
const userName = mw.config.get('wgRelevantUserName');
// Ensure userName is available (RC may not set wgRelevantUserName)
let userName = mw.config.get('wgRelevantUserName');
if (!userName && contributions && contributions.length && contributions[0].user) {
userName = contributions[0].user;
}
const reason = $('#rollback-reason').val() === 'other'
const reason = $('#rollback-reason').val() === 'other'
? $('#custom-reason').val()
? $('#custom-reason').val()
: $('#rollback-reason option:selected').text();
: $('#rollback-reason option:selected').text();
const summary = userName
? `[[Help:Revert a page|Reverted]] edit(s) by [[User:${userName}|${userName}]]: ${reason} ([[User:BZPN/MassRollback|MR]])`
: `[[Help:Revert a page|Reverted]] edit(s): ${reason} ([[User:BZPN/MassRollback|MR]])`;
const api = new mw.Api();
const api = new mw.Api();

const promises = contributions.map(contrib => {
// If username still not available, fallback to extracting from contributions array
const summary = `Reverted edit by [[User:${userName}|${userName}]]: ${reason}`;
return api.postWithToken('csrf', {
if (!userName) {
const fromList = (contributions || []).map(c => c.user).filter(Boolean)[0];
if (fromList) userName = fromList;
}

// Group edits by page title
const groups = {};
contributions.forEach(contrib => {
if (!groups[contrib.title]) groups[contrib.title] = [];
groups[contrib.title].push(contrib);
});

const promises = [];
for (let title in groups) {
let group = groups[title].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
let latest = group[0];
let currentParent = latest.parentid;
for (let i = 1; i < group.length; i++) {
const contrib = group[i];
if (parseInt(contrib.revid, 10) === parseInt(currentParent, 10)) {
currentParent = contrib.parentid;
} else {
break;
}
}
promises.push(api.postWithToken('csrf', {
action: 'edit',
action: 'edit',
undoafter: contrib.parentid,
undo: latest.revid,
undo: contrib.revid,
undoafter: currentParent,
title: contrib.title,
title: title,
summary: summary
summary: summary
});
}));
});
}
return Promise.all(promises);
return Promise.all(promises);
},
},
Line 361: Line 1,017:
title: $(this).data('title'),
title: $(this).data('title'),
revid: $(this).data('revid'),
revid: $(this).data('revid'),
parentid: $(this).data('parentid')
parentid: $(this).data('parentid'),
timestamp: $(this).closest('li').find('.mw-contributions-timestamp').text() || new Date().toISOString()
};
};
}).get();
}).get();
Line 369: Line 1,026:
return;
return;
}
}
const confirmed = await this.confirmAction('Are you sure you want to rollback the selected edits?', selected.length);
const confirmResult = await this.confirmAction('Are you sure you want to rollback the selected edits?', selected.length);
if (!confirmed) return;
if (!confirmResult || !confirmResult.confirm) return;
try {
try {
await this.performRollback(selected);
await this.performRollback(selected);
mw.notify(`Successfully rolled back ${selected.length} edits.`, { type: 'success' });
mw.notify(`Successfully rolled back ${selected.length} edits.`, { type: 'success' });
if (confirmResult.leaveWarning) {
const targetUser = (selected[0] && selected[0].user) ? selected[0].user : null;
await this.leaveUserWarning(confirmResult.warningType, targetUser);
}
if (confirmResult.reportVIP) {
await this.performVIPReport(confirmResult.vip);
}
location.reload();
location.reload();
} catch (error) {
} catch (error) {
Line 386: Line 1,050:
title: $(this).data('title'),
title: $(this).data('title'),
revid: $(this).data('revid'),
revid: $(this).data('revid'),
parentid: $(this).data('parentid')
parentid: $(this).data('parentid'),
timestamp: new Date().toISOString() // Jeśli API już zwraca timestamp, można go tutaj użyć
};
};
}).get();
}).get();
Line 394: Line 1,059:
return;
return;
}
}
const confirmed = await this.confirmAction('Are you sure you want to rollback the selected filtered edits?', selected.length);
const confirmResult = await this.confirmAction('Are you sure you want to rollback the selected filtered edits?', selected.length);
if (!confirmed) return;
if (!confirmResult || !confirmResult.confirm) return;
try {
try {
await this.performRollback(selected);
await this.performRollback(selected);
mw.notify(`Successfully rolled back ${selected.length} filtered edits.`, { type: 'success' });
mw.notify(`Successfully rolled back ${selected.length} filtered edits.`, { type: 'success' });
if (confirmResult.leaveWarning) {
const targetUser = (selected[0] && selected[0].user) ? selected[0].user : null;
await this.leaveUserWarning(confirmResult.warningType, targetUser);
}
if (confirmResult.reportVIP) {
await this.performVIPReport(confirmResult.vip);
}
location.reload();
location.reload();
} catch (error) {
} catch (error) {
Line 410: Line 1,082:
const api = new mw.Api();
const api = new mw.Api();
try {
try {
// Dodajemy "timestamp" by móc grupować edycje
const data = await api.get({
const data = await api.get({
action: 'query',
action: 'query',
Line 415: Line 1,088:
ucuser: userName,
ucuser: userName,
uclimit: 'max',
uclimit: 'max',
ucprop: 'ids|title|parentid'
ucprop: 'ids|title|parentid|timestamp'
});
});
const contributions = data.query.usercontribs;
const contributions = data.query.usercontribs;
Line 422: Line 1,095:
return;
return;
}
}
const confirmed = await this.confirmAction('Are you sure you want to rollback ALL edits?', contributions.length);
const confirmResult = await this.confirmAction('Are you sure you want to rollback ALL edits?', contributions.length);
if (!confirmed) return;
if (!confirmResult || !confirmResult.confirm) return;
await this.performRollback(contributions);
await this.performRollback(contributions);
mw.notify(`Successfully rolled back all ${contributions.length} edits.`, { type: 'success' });
mw.notify(`Successfully rolled back all ${contributions.length} edits.`, { type: 'success' });
if (confirmResult.leaveWarning) {
const targetUser = (contributions[0] && contributions[0].user) ? contributions[0].user : null;
await this.leaveUserWarning(confirmResult.warningType, targetUser);
}
if (confirmResult.reportVIP) {
await this.performVIPReport(confirmResult.vip);
}
location.reload();
location.reload();
} catch (error) {
} catch (error) {
mw.notify('Error during rollback.', { type: 'error' });
mw.notify('Error during rollback.', { type: 'error' });
console.error(error);
console.error(error);
}
},

performVIPReport: async function(vip) {
const api = new mw.Api();
const uid = mw.config.get('wgRelevantUserName');
const isIP = /^(?:\d{1,3}\.){3}\d{1,3}$/.test(uid) || uid.includes(':');

const typeMap = {
final: 'vandalism after final warning',
postblock: 'vandalism after recent release of block',
spambot: 'account is evidently a spambot or a compromised account',
vandalonly: 'actions evidently indicate a vandalism-only account',
promoonly: 'account is being used only for promotional purposes'
};
const typesText = (vip.types || []).map(v => typeMap[v] || 'unknown reason').join('; ');

let reason = '';
if (vip.page) {
const safe = vip.page.replace(/^(Image|Category|File):/i, ':$1:');
reason += `On [[${safe}]]`;
if (vip.badid) {
reason += ` ({{diff|${vip.page}|${vip.badid}|${vip.goodid || ''}|diff}})`;
}
reason += ':';
}
if (typesText) reason += (reason ? ' ' : '') + typesText;
if (vip.comment) reason += (reason ? '. ' : '') + vip.comment;
reason += '. ' + '~~'+'~~';
reason = reason.replace(/\r?\n/g, "\n*:");

const vandalTpl = isIP ? 'IPvandal' : 'vandal';
const userParam = /=/.test(uid) ? `1=${uid}` : uid;
const entry = `\n*{{${vandalTpl}|${userParam}}} &ndash; ${reason}`;

const title = 'Wikipedia:Vandalism in progress';
let sectionIndex = null;
try {
const parseData = await api.get({ action: 'parse', page: title, prop: 'sections' });
if (parseData && parseData.parse && Array.isArray(parseData.parse.sections)) {
const sec = parseData.parse.sections.find(s => (s.line || '').trim().toLowerCase() === 'user-reported');
if (sec) sectionIndex = sec.index;
}
} catch (e) {}

if (sectionIndex) {
return api.postWithToken('csrf', {
action: 'edit',
title: title,
section: sectionIndex,
appendtext: entry,
summary: `Reporting [[Special:Contributions/${uid}|${uid}]]. ([[User:BZPN/MassRollback|MR]])`
});
} else {
return api.postWithToken('csrf', {
action: 'edit',
title: title,
section: 'new',
sectiontitle: 'User-reported',
text: entry.trimStart(),
summary: `Reporting [[Special:Contributions/${uid}|${uid}]]. ([[User:BZPN/MassRollback|MR]])`
});
}
},

// Leave a talk page warning using a chosen template (subst) under current month section
leaveUserWarning: async function(templateName, targetUser) {
const api = new mw.Api();
const userName = targetUser || mw.config.get('wgRelevantUserName');
if (!userName) {
mw.notify('Could not determine user name for talk page warning.', { type: 'error' });
return;
}
const title = `User talk:${userName}`;

// Build current month/year (English)
const monthNames = ["January","February","March","April","May","June","July","August","September","October","November","December"];
const now = new Date();
const monthStr = `${monthNames[now.getUTCMonth()]} ${now.getUTCFullYear()}`;

// Compose warning w/ subst and signature
const insertion = `{{`+`subst:${templateName}}} `+`~~`+`~~`+`\n`;

// Try to find existing section index via parse
let sectionIndex = null;
try {
const parseData = await api.get({ action: 'parse', page: title, prop: 'sections' });
if (parseData && parseData.parse && Array.isArray(parseData.parse.sections)) {
const sec = parseData.parse.sections.find(s => (s.line || '').trim() === monthStr);
if (sec) sectionIndex = sec.index; // string index
}
} catch (e) {
// If page doesn't exist or parse fails, we'll create a new section below
}

if (sectionIndex) {
// Append to existing month section
return api.postWithToken('csrf', {
action: 'edit',
title: title,
section: sectionIndex,
appendtext: `\n\n${insertion}`,
summary: `Automated warning: ${templateName} ([[User:BZPN/MassRollback|MR]])`
});
} else {
// Create a new month section and post inside it
return api.postWithToken('csrf', {
action: 'edit',
title: title,
section: 'new',
sectiontitle: monthStr,
text: insertion,
summary: `Automated warning: ${templateName} ([[User:BZPN/MassRollback|MR]])`
});
}
}
}
}