User:BZPN/MassRollback2.js: Difference between revisions

From Test Wiki
Jump to navigation Jump to search
Content deleted Content added
BZPN (talk | contribs)
No edit summary
BZPN (talk | contribs)
No edit summary
 
(11 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() {
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
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 MassRollback
} else {
</button>
$('#mw-content-text').prepend($ui);
`);
}
$toggleButton.on('click', function() {
$('#mass-rollback-container').slideToggle();
const btn = $(this);
btn.text(btn.text() === 'Show MassRollback' ? 'Hide MassRollback' : 'Show MassRollback');
});
$('#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').css("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]])`
});
}
}
}
}

Latest revision as of 09:56, 13 September 2025

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

    const MassRollback = {
        init: function() {
            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;
            }
        },

        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() {
            const $ui = $(`
                <details id="mass-rollback-details" open>
                    <summary>Mass rollback</summary>
                    <div id="mass-rollback-container">
                        <div id="stats-section" class="mr-section">
                            <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" class="ooui-button">Refresh</button>
                            </p>
                        </div>
                        <div id="filters-section" class="mr-section">
                            <h4>Filter edits</h4>
                            <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 id="actions-section" class="mr-section" style="text-align:center;">
                            <button id="rollback-selected" class="ooui-button primary">Rollback selected</button>
                            <button id="rollback-all" class="ooui-button danger">Rollback all</button>
                        </div>
                        <div id="filtered-edits-container" class="mr-section" style="display:none;">
                            <h4>Filtered edits</h4>
                            <div class="mr-row">
                                <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>
                </details>
            `);

            // Put the drawer at the top of the content area
            $('#mw-content-text').prepend($ui);
        },

        // RC version: collapsed by default, without statistics section
        createRCUI: function() {
            const $ui = $(`
                <details id="mass-rollback-details">
                    <summary>Mass rollback</summary>
                    <div id="mass-rollback-container">
                        <div id="filters-section" class="mr-section">
                            <h4>Filter edits</h4>
                            <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>
                </details>
            `);
            // Insert after RC filters table if exists, else at top
            const $anchor = $('#mw-rcfilters-ui-table');
            if ($anchor.length) {
                $anchor.after($ui);
            } else {
                $('#mw-content-text').prepend($ui);
            }
        },

        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 checked = $(this).is(':checked');
                $('#filtered-edits-table tbody input[type="checkbox"]').prop('checked', checked);
            });
            $('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();

            // 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',
                list: 'usercontribs',
                ucuser: userName,
                uclimit: 1,
                ucprop: 'timestamp',
                ucdir: 'newer'
            });

            // 3) Get last contribution (newest)
            const lastPromise = api.get({
                action: 'query',
                list: 'usercontribs',
                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) {
            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'};
            return mapping[ns] || ns;
        },

        applyFilters: function(contribs) {
            const start = $('#start-date').val() ? new Date($('#start-date').val()) : null;
            const end = $('#end-date').val() ? new Date($('#end-date').val()) : null;
            let nsFilter = $('#namespace-filter').val() || [];
            const sizeFilter = $('#size-filter').val();
            const sortOrder = $('#sort-order').val();
            if (nsFilter.includes('all')) nsFilter = [];
            let filtered = contribs.filter(c => {
                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;
                if (sizeFilter) {
                    const s = c.size || 0;
                    if (sizeFilter === 'small' && s >= 50) return false;
                    if (sizeFilter === 'medium' && (s < 50 || s > 500)) return false;
                    if (sizeFilter === 'large' && s <= 500) return false;
                }
                return true;
            });
            filtered.sort((a,b) => 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:'5000',
                ucprop:'ids|title|timestamp|size|ns|parentid'
            }).done((data) => {
                const filtered = this.applyFilters(data.query.usercontribs);
                const $tbody = $('#filtered-edits-table tbody').empty();
                if (!filtered.length) {
                    $tbody.append('<tr><td colspan="6" style="text-align:center;">No edits match the selected filters.</td></tr>');
                } else {
                    filtered.forEach(c => {
                        $tbody.append(`
                            <tr>
                                <td>${c.revid}</td>
                                <td>${c.title}</td>
                                <td>${new Date(c.timestamp).toLocaleString()}</td>
                                <td>${this.getNamespaceName(c.ns)}</td>
                                <td>${c.size||0}</td>
                                <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>`);
                    });
                }
                $('#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) {
            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:620px;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: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('#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();
                    resolve({ confirm: true, leaveWarning: leave, warningLevel: level, warningType: type, reportVIP, vip }); 
                });
                $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();
                    resolve({ confirm: true, reasonValue, customReason, leaveWarning: leave, warningLevel: level, warningType: type, reportVIP, vip });
                });
                $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) {
            // 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' 
                ? $('#custom-reason').val() 
                : $('#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();

            // If username still not available, fallback to extracting from contributions array
            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',
                    undo: latest.revid,
                    undoafter: currentParent,
                    title: title,
                    summary: summary
                }));
            }
            return Promise.all(promises);
        },

        rollbackSelected: async function() {
            const selected = $('.rollback-checkbox:checked').map(function() {
                return {
                    title: $(this).data('title'),
                    revid: $(this).data('revid'),
                    parentid: $(this).data('parentid'),
                    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 confirmResult = await this.confirmAction('Are you sure you want to rollback the selected edits?', selected.length);
            if (!confirmResult || !confirmResult.confirm) return;
            try {
                await this.performRollback(selected);
                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();
            } 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 confirmResult = await this.confirmAction('Are you sure you want to rollback the selected filtered edits?', selected.length);
            if (!confirmResult || !confirmResult.confirm) return;
            try {
                await this.performRollback(selected);
                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();
            } 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 confirmResult = await this.confirmAction('Are you sure you want to rollback ALL edits?', contributions.length);
                if (!confirmResult || !confirmResult.confirm) return;
                await this.performRollback(contributions);
                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();
            } catch (error) {
                mw.notify('Error during rollback.', { type: '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]])`
                });
            }
        }
    };

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