User:BZPN/MassRollback2.js: Difference between revisions
From Test Wiki
Content deleted Content added
No edit summary |
No edit summary |
||
| (3 intermediate revisions by the same user not shown) | |||
| Line 3: | Line 3: | ||
const MassRollback = { |
const MassRollback = { |
||
init: function() { |
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; |
return; |
||
} |
} |
||
this.createUI(); |
|||
// Try to enhance to Codex after UI is present (only if styles loaded) |
|||
if (mw.loader.getState && (mw.loader.getState('codex-styles') === 'ready' || mw.loader.getState('codex-search-styles') === 'ready')) { |
|||
this.applyCodexClasses(); |
|||
} |
|||
this.bindEvents(); |
|||
this.fetchUserStats(); |
|||
}, |
}, |
||
injectStyles: function() { |
|||
const |
const styles = ` |
||
/* Container as a clean drawer */ |
|||
<div id=\"mass-rollback-container\" class=\"mw-portlet\" style=\"display:none;\">\n <h3 class=\"mw-headline\" style=\"margin-top:0;\">Mass rollback tool</h3>\n\n <div id=\"stats-section\" class=\"mw-message-box mw-message-box-notice\" style=\"margin-bottom:1em;\">\n <h4 class=\"mw-ui-headline\" style=\"margin:0 0 .5em 0;\">User statistics</h4>\n <p>Edits: <strong id=\"total-edits\">-</strong></p>\n <p>First edit: <span id=\"first-edit\">-</span></p>\n <p>Recent edit: <span id=\"last-edit\">-</span> <button id=\"refresh-stats\" class=\"mw-ui-button mw-ui-progressive\">Refresh</button></p>\n </div>\n\n <div id=\"filters-section\" class=\"mw-portlet-body\" style=\"margin-bottom:1em;\">\n <h4 class=\"mw-ui-headline\">Filter edits</h4>\n <div class=\"mw-input\" style=\"margin-bottom:.5em;\">\n <label class=\"mw-label\" style=\"margin-right:.5em;\">Date from:</label>\n <input type=\"date\" id=\"start-date\" class=\"mw-ui-input\">\n <label class=\"mw-label\" style=\"margin:0 .5em;\">to:</label>\n <input type=\"date\" id=\"end-date\" class=\"mw-ui-input\">\n </div>\n <div class=\"mw-input\" style=\"margin-bottom:.5em;\">\n <label class=\"mw-label\" style=\"margin-right:.5em;\">Namespace:</label>\n <select id=\"namespace-filter\" multiple class=\"mw-ui-input\" style=\"min-width:150px;\">\n <option value=\"all\">All</option>\n <option value=\"0\">Main</option>\n <option value=\"1\">Talk</option>\n <option value=\"2\">User</option>\n <option value=\"3\">User talk</option>\n <option value=\"4\">Project</option>\n <option value=\"5\">Project talk</option>\n <option value=\"6\">File</option>\n <option value=\"7\">File talk</option>\n <option value=\"8\">MediaWiki</option>\n <option value=\"9\">MediaWiki talk</option>\n <option value=\"10\">Template</option>\n <option value=\"11\">Template talk</option>\n <option value=\"12\">Help</option>\n <option value=\"13\">Help talk</option>\n <option value=\"14\">Category</option>\n <option value=\"15\">Category talk</option>\n <option value=\"100\">Portal</option>\n <option value=\"101\">Portal talk</option>\n </select>\n </div>\n <div class=\"mw-input\" style=\"margin-bottom:.5em;\">\n <label class=\"mw-label\" style=\"margin-right:.5em;\">Edit size:</label>\n <select id=\"size-filter\" class=\"mw-ui-input\">\n <option value=\"\">Any</option>\n <option value=\"small\">Small (< 50 bytes)</option>\n <option value=\"medium\">Medium (50-500 bytes)</option>\n <option value=\"large\">Large (> 500 bytes)</option>\n </select>\n </div>\n <div class=\"mw-input\" style=\"margin-bottom:.5em;\">\n <label class=\"mw-label\" style=\"margin-right:.5em;\">Sort order:</label>\n <select id=\"sort-order\" class=\"mw-ui-input\">\n <option value=\"desc\">Latest first</option>\n <option value=\"asc\">Oldest first</option>\n </select>\n </div>\n <div class=\"mw-input\" style=\"margin-bottom:.5em;\">\n <button id=\"clear-filters\" class=\"mw-ui-button\">Clear filters</button>\n <button id=\"show-filtered\" class=\"mw-ui-button mw-ui-progressive\" style=\"margin-left:.5em;\">Show filtered edits</button>\n </div>\n </div>\n\n <div id=\"reason-section\" class=\"mw-portlet-body\" style=\"margin-bottom:1em;\">\n <h4 class=\"mw-ui-headline\">Rollback reason</h4>\n <select id=\"rollback-reason\" class=\"mw-ui-input\">\n <option value=\"\"></option>\n <option value=\"vandalism\">Vandalism</option>\n <option value=\"spam\">Spam</option>\n <option value=\"test\">Test rollback</option>\n <option value=\"teste\">Test edit</option>\n <option value=\"rules-violation\">Violation</option>\n <option value=\"other\">Other</option>\n </select>\n <input type=\"text\" id=\"custom-reason\" placeholder=\"Enter custom reason\" class=\"mw-ui-input\" style=\"display:none; margin-top:.5em; width:100%;\">\n </div>\n\n <div id=\"actions-section\" class=\"mw-portlet-body\" style=\"text-align:center;\">\n <button id=\"rollback-selected\" class=\"mw-ui-button mw-ui-progressive\" style=\"margin-right:.5em;\">Rollback selected</button>\n <button id=\"rollback-all\" class=\"mw-ui-button mw-ui-destructive\">Rollback all</button>\n </div>\n </div>\n\n <div id=\"filtered-edits-container\" class=\"mw-portlet\" style=\"display:none;\">\n <h4 class=\"mw-ui-headline\">Filtered edits</h4>\n <div class=\"mw-input\" style=\"margin-bottom:.5em;\">\n <input type=\"checkbox\" id=\"select-all\"> <label for=\"select-all\">Select all</label>\n </div>\n <table id=\"filtered-edits-table\" class=\"wikitable\" style=\"width:100%;\">\n <thead>\n <tr>\n <th>Revid</th>\n <th>Title</th>\n <th>Timestamp</th>\n <th>Namespace</th>\n <th>Size</th>\n <th>Select</th>\n </tr>\n </thead>\n <tbody>\n <!-- Wstawiane dynamicznie -->\n </tbody>\n </table>\n <div style=\"text-align:center; margin-top:.5em;\">\n <button id=\"rollback-selected-filtered\" class=\"mw-ui-button mw-ui-progressive\">Rollback selected filtered edits</button>\n </div>\n </div>\n `); |
|||
#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; } |
|||
<button id=\"mass-rollback-toggle\" class=\"mw-ui-button mw-ui-progressive\" style=\"margin-bottom:.75em;\">\n Show/hide MassRollback\n </button>\n `); |
|||
.mr-section + .mr-section { margin-top: 14px; } |
|||
.mr-row { margin: .6em 0; display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center; } |
|||
$toggleButton.on('click', function() { |
|||
.mr-row label { margin-right: 6px; color: #202122; } |
|||
}); |
|||
$('#mw-content-text').prepend($toggleButton).prepend($container); |
|||
}, |
|||
/* Inputs */ |
|||
// Apply Codex CSS-only classes when styles are available; keep MW UI as fallback |
|||
.ooui-input, .ooui-select { |
|||
applyCodexClasses: function() { |
|||
width: 100%; |
|||
max-width: 360px; |
|||
// Buttons: map MediaWiki UI modifiers to Codex equivalents and keep both for compatibility |
|||
box-sizing: border-box; |
|||
$('#mass-rollback-container').find('button.mw-ui-button').each(function() { |
|||
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"; } |
|||
$('#mass-rollback-container').find('input[type="text"], input[type="date"], select').each(function() { |
|||
} |
|||
`; |
|||
$('<style>').text(styles).appendTo('head'); |
|||
// Optional: wrap with Codex container if not already wrapped |
|||
}, |
|||
if (!$el.parent().hasClass('cdx-text-input')) { |
|||
$el.wrap('<div class="cdx-text-input"></div>'); |
|||
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); |
|||
} |
} |
||
}, |
}, |
||
| Line 67: | Line 333: | ||
$('#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 75: | 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 81: | 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 |
const checked = $(this).is(':checked'); |
||
$('#filtered-edits-table tbody input[type="checkbox"]').prop('checked', |
$('#filtered-edits-table tbody input[type="checkbox"]').prop('checked', checked); |
||
}); |
}); |
||
// Dodaj checkbox przy edycjach w widoku kontrybucji |
|||
$('li[data-mw-revid]').each(function() { |
$('li[data-mw-revid]').each(function() { |
||
const $li = $(this); |
const $li = $(this); |
||
| Line 99: | Line 360: | ||
'data-parentid': parentid, |
'data-parentid': parentid, |
||
'data-title': title, |
'data-title': title, |
||
style: 'margin-right: |
style: 'margin-right:5px;' |
||
}); |
}); |
||
$li.prepend($checkbox); |
$li.prepend($checkbox); |
||
| Line 109: | Line 370: | ||
const api = new mw.Api(); |
const api = new mw.Api(); |
||
// |
// 1) Get accurate total edits from 'users' prop |
||
const |
const userPromise = api.get({ |
||
action: 'query', |
action: 'query', |
||
list: 'users', |
list: 'users', |
||
| Line 116: | Line 377: | ||
usprop: 'editcount' |
usprop: 'editcount' |
||
}); |
}); |
||
const firstEditReq = api.get({ |
|||
// 2) Get first contribution (oldest) |
|||
const firstPromise = api.get({ |
|||
action: 'query', |
action: 'query', |
||
list: 'usercontribs', |
list: 'usercontribs', |
||
ucuser: userName, |
ucuser: userName, |
||
uclimit: 1, |
uclimit: 1, |
||
ucprop: 'timestamp', |
|||
ucdir: 'newer' |
|||
}); |
}); |
||
const lastEditReq = api.get({ |
|||
// 3) Get last contribution (newest) |
|||
const lastPromise = api.get({ |
|||
action: 'query', |
action: 'query', |
||
list: 'usercontribs', |
list: 'usercontribs', |
||
ucuser: userName, |
ucuser: userName, |
||
uclimit: 1, |
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]) || {}; |
|||
try { |
|||
const editcount = typeof user.editcount === 'number' ? user.editcount : '-'; |
|||
const first = (firstData.query && firstData.query.usercontribs && firstData.query.usercontribs[0]) || null; |
|||
$('#total-edits').text(editcount != null ? editcount : '-'); |
|||
const last = (lastData.query && lastData.query.usercontribs && lastData.query.usercontribs[0]) || null; |
|||
} catch (e) { |
|||
$('#total-edits').text('-'); |
|||
$('#total-edits').text(editcount); |
|||
$('#first-edit').text(first ? new Date(first.timestamp).toLocaleDateString() : '-'); |
|||
try { |
|||
$('#last-edit').text(last ? new Date(last.timestamp).toLocaleDateString() : '-'); |
|||
const firstList = firstData[0].query.usercontribs || []; |
|||
}).catch(() => { |
|||
$('#first-edit').text(firstList.length ? new Date(firstList[0].timestamp).toLocaleDateString() : '-'); |
|||
// Fallback to previous approach if anything fails |
|||
api.get({ |
|||
action: 'query', |
|||
list: 'usercontribs', |
|||
ucuser: userName, |
|||
uclimit: '5000', |
|||
$('#last-edit').text(lastList.length ? new Date(lastList[0].timestamp).toLocaleDateString() : '-'); |
|||
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 = { |
|||
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( |
applyFilters: function(contribs) { |
||
const |
const start = $('#start-date').val() ? new Date($('#start-date').val()) : null; |
||
const |
const end = $('#end-date').val() ? new Date($('#end-date').val()) : null; |
||
let |
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 = []; |
|||
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; |
|||
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 |
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; |
|||
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 223: | Line 463: | ||
const api = new mw.Api(); |
const api = new mw.Api(); |
||
api.get({ |
api.get({ |
||
action: |
action:'query', |
||
list: |
list:'usercontribs', |
||
ucuser: |
ucuser:userName, |
||
uclimit: |
uclimit:'5000', |
||
ucprop: |
ucprop:'ids|title|timestamp|size|ns|parentid' |
||
}).done((data) => { |
}).done((data) => { |
||
const |
const filtered = this.applyFilters(data.query.usercontribs); |
||
const |
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>'); |
|||
$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( |
filtered.forEach(c => { |
||
$tbody.append(` |
|||
<tr> |
<tr> |
||
<td |
<td>${c.revid}</td> |
||
<td |
<td>${c.title}</td> |
||
<td |
<td>${new Date(c.timestamp).toLocaleString()}</td> |
||
<td |
<td>${this.getNamespaceName(c.ns)}</td> |
||
<td |
<td>${c.size||0}</td> |
||
<td style=" |
<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' |
$('#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 |
return new Promise(resolve => { |
||
const $modal = $(` |
const $modal = $(` |
||
<div style="position: |
<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. |
<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>${ |
<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> |
|||
<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); |
||
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 }); }); |
|||
}); |
}); |
||
}, |
}, |
||
| Line 287: | Line 962: | ||
// Nowa wersja: grupujemy edycje wg tytułu i dla każdej grupy wykrywamy ciąg kolejnych edycji. |
// Nowa wersja: grupujemy edycje wg tytułu i dla każdej grupy wykrywamy ciąg kolejnych edycji. |
||
performRollback: function(contributions) { |
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' |
const reason = $('#rollback-reason').val() === 'other' |
||
? $('#custom-reason').val() |
? $('#custom-reason').val() |
||
: $('#rollback-reason option:selected').text(); |
: $('#rollback-reason option:selected').text(); |
||
const summary = |
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(); |
||
// If username still not available, fallback to extracting from contributions array |
|||
// Grupujemy edycje wg tytułu strony |
|||
if (!userName) { |
|||
const fromList = (contributions || []).map(c => c.user).filter(Boolean)[0]; |
|||
if (fromList) userName = fromList; |
|||
} |
|||
// Group edits by page title |
|||
const groups = {}; |
const groups = {}; |
||
contributions.forEach(contrib => { |
contributions.forEach(contrib => { |
||
if (!groups[contrib.title]) |
if (!groups[contrib.title]) groups[contrib.title] = []; |
||
groups[contrib.title] = []; |
|||
} |
|||
groups[contrib.title].push(contrib); |
groups[contrib.title].push(contrib); |
||
}); |
}); |
||
const promises = []; |
const promises = []; |
||
// Dla każdej strony – sortuj wg daty malejąco i wykryj ciąg edycji |
|||
for (let title in groups) { |
for (let title in groups) { |
||
let group = groups[title].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); |
let group = groups[title].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); |
||
let latest = group[0]; |
let latest = group[0]; |
||
// Rozpoczynamy ciąg od najnowszej edycji |
|||
let chain = [latest]; |
|||
let currentParent = latest.parentid; |
let currentParent = latest.parentid; |
||
// Przeglądamy kolejne edycje dla tego samego tytułu |
|||
for (let i = 1; i < group.length; i++) { |
for (let i = 1; i < group.length; i++) { |
||
const contrib = group[i]; |
const contrib = group[i]; |
||
// Jeżeli kolejna edycja jest dokładnie poprzednikiem poprzednio znalezionego elementu, dodajemy do ciągu |
|||
if (parseInt(contrib.revid, 10) === parseInt(currentParent, 10)) { |
if (parseInt(contrib.revid, 10) === parseInt(currentParent, 10)) { |
||
chain.push(contrib); |
|||
currentParent = contrib.parentid; |
currentParent = contrib.parentid; |
||
} else { |
} else { |
||
| Line 322: | Line 1,001: | ||
} |
} |
||
} |
} |
||
// Wykonaj rollback tylko, jeśli mamy przynajmniej jedną edycję (zawsze prawda) |
|||
promises.push(api.postWithToken('csrf', { |
promises.push(api.postWithToken('csrf', { |
||
action: 'edit', |
action: 'edit', |
||
| Line 335: | Line 1,013: | ||
rollbackSelected: async function() { |
rollbackSelected: async function() { |
||
// Pobieramy zaznaczone edycje z listy kontrybucji |
|||
const selected = $('.rollback-checkbox:checked').map(function() { |
const selected = $('.rollback-checkbox:checked').map(function() { |
||
return { |
return { |
||
| Line 341: | Line 1,018: | ||
revid: $(this).data('revid'), |
revid: $(this).data('revid'), |
||
parentid: $(this).data('parentid'), |
parentid: $(this).data('parentid'), |
||
// Wartość timestamp może nie być dostępna w tym widoku – można opcjonalnie dodać dodatkowy pobór danych |
|||
timestamp: $(this).closest('li').find('.mw-contributions-timestamp').text() || new Date().toISOString() |
timestamp: $(this).closest('li').find('.mw-contributions-timestamp').text() || new Date().toISOString() |
||
}; |
}; |
||
| Line 350: | Line 1,026: | ||
return; |
return; |
||
} |
} |
||
const |
const confirmResult = await this.confirmAction('Are you sure you want to rollback the selected edits?', selected.length); |
||
if (! |
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 376: | Line 1,059: | ||
return; |
return; |
||
} |
} |
||
const |
const confirmResult = await this.confirmAction('Are you sure you want to rollback the selected filtered edits?', selected.length); |
||
if (! |
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 405: | Line 1,095: | ||
return; |
return; |
||
} |
} |
||
const |
const confirmResult = await this.confirmAction('Are you sure you want to rollback ALL edits?', contributions.length); |
||
if (! |
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) { |
||
| Line 414: | Line 1,111: | ||
console.error(error); |
console.error(error); |
||
} |
} |
||
} |
}, |
||
}; |
|||
performVIPReport: async function(vip) { |
|||
// Load stable MW modules and try Codex CSS-only styles if present (1.39+) |
|||
const api = new mw.Api(); |
|||
(function loadModulesAndInit() { |
|||
const uid = mw.config.get('wgRelevantUserName'); |
|||
var baseDeps = [ |
|||
const isIP = /^(?:\d{1,3}\.){3}\d{1,3}$/.test(uid) || uid.includes(':'); |
|||
'mediawiki.api', |
|||
'mediawiki.util', |
|||
const typeMap = { |
|||
final: 'vandalism after final warning', |
|||
]; |
|||
postblock: 'vandalism after recent release of block', |
|||
var codexDep = null; |
|||
spambot: 'account is evidently a spambot or a compromised account', |
|||
try { |
|||
vandalonly: 'actions evidently indicate a vandalism-only account', |
|||
promoonly: 'account is being used only for promotional purposes' |
|||
if (mw.loader.getState('codex-search-styles')) { |
|||
}; |
|||
const typesText = (vip.types || []).map(v => typeMap[v] || 'unknown reason').join('; '); |
|||
codexDep = 'codex-styles'; |
|||
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'; |
|||
// Also load legacy mediawiki.ui when present to keep fallback look |
|||
const userParam = /=/.test(uid) ? `1=${uid}` : uid; |
|||
var legacyUI = []; |
|||
const entry = `\n*{{${vandalTpl}|${userParam}}} – ${reason}`; |
|||
if (mw.loader.getState('mediawiki.ui')) legacyUI.push('mediawiki.ui'); |
|||
if (mw.loader.getState('mediawiki.ui.button')) legacyUI.push('mediawiki.ui.button'); |
|||
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(); |
|||
}); |
|||
// Final fallback: try to init anyway |
|||
$(function() { try { MassRollback.init(); } catch (e) { console.error(e); } }); |
|||
}); |
|||
})(); |
|||
})(jQuery, mediaWiki); |
})(jQuery, mediaWiki); |
||