User:Daniel Quinlan/Scripts/Unfiltered.js
Appearance
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump. This code will be executed when previewing this page. |
![]() | This user script seems to have a documentation page at User:Daniel Quinlan/Scripts/Unfiltered. |
'use strict';
const usageCounters = {};
function incrementCounter(key) {
usageCounters[key] = (usageCounters[key] || 0) + 1;
}
function saveCounters() {
const countersKey = 'unfiltered-counters';
const mergedCounters = mw.storage.getObject(countersKey);
if (mergedCounters == null) return;
for (const [key, count] of Object.entries(usageCounters)) {
mergedCounters[key] = (mergedCounters[key] || 0) + count;
}
mw.storage.setObject(countersKey, mergedCounters);
}
class Mutex {
constructor() {
this.lock = Promise.resolve();
}
run(fn) {
const p = this.lock.then(fn, fn);
this.lock = p.finally(() => {});
return p;
}
}
class RevisionData {
constructor(api, special, relevantUser, rights) {
this.api = api;
this.special = special;
this.relevantUser = relevantUser;
this.rights = rights;
this.revElements = {};
this.noRevElements = {};
this.firstRevid = null;
this.lastRevid = null;
this.nextRevid = null;
const pager = document.querySelector('.mw-pager-navigation-bar');
this.hasOlder = !!pager?.querySelector('a.mw-lastlink');
this.hasNewer = !!pager?.querySelector('a.mw-firstlink');
this.isoTimezone = this.getIsoTimezone();
const listItems = document.querySelectorAll('ul.mw-contributions-list > li[data-mw-revid]');
this.timestamps = {};
for (const li of listItems) {
const revid = Number(li.getAttribute('data-mw-revid'));
if (!revid) continue;
this.revElements[revid] = li;
if (!this.firstRevid) {
this.firstRevid = revid;
}
this.lastRevid = revid;
this.timestamps[revid] = this.extractTimestamp(li);
}
this.userContribsPromise = this.fetchUserContribs();
this.timestampsPromise = this.fetchRevisions();
this.noRevids = {};
this.noRevidIndex = 0;
}
getIsoTimezone() {
if (mw.user.options.get('date') !== 'ISO 8601') return null;
const correction = mw.user.options.get('timecorrection') || '';
const match = correction.match(/^(?:Offset|System)\|(-)?(\d+)$/);
if (!match) return null;
const sign = match[1] || '+';
const offset = Number(match[2]);
const pad = n => String(n).padStart(2, '0');
return `${sign}${pad(Math.floor(offset / 60))}:${pad(offset % 60)}`;
}
extractTimestamp(li) {
if (this.special === 'DeletedContributions') return this.extractDeletedTimestamp(li);
if (this.isoTimezone) return this.extractVisibleTimestamp(li);
return null;
}
extractDeletedTimestamp(li) {
const link = li.querySelector('.mw-deletedcontribs-tools > a[title="Special:Undelete"]');
if (!link) return null;
const match = link.href?.match(/[&?]timestamp=(\d{14})\b/);
if (!match) return null;
const t = match[1];
return `${t.slice(0, 4)}-${t.slice(4, 6)}-${t.slice(6, 8)}T${t.slice(8, 10)}:${t.slice(10, 12)}:${t.slice(12, 14)}Z`;
}
extractVisibleTimestamp(li) {
const text = li.querySelector('.mw-changeslist-date')?.textContent
if (!text) return null;
const match = text.match(/^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d)/);
if (!match) return null;
const textTime = match[1];
if (this.isoTimezone === '+00:00') return textTime + 'Z';
const date = new Date(textTime + this.isoTimezone);
if (isNaN(date)) return null;
return date.toISOString().replace(/\.\d+Z$/, 'Z');
}
async fetchUserContribs() {
if (!this.relevantUser || !this.hasMissingTimestamps()) return;
incrementCounter('usercontribs');
const extra = n => n + Math.ceil(Math.log10(n / 10 + 1));
const limit = this.rights.apihighlimits ? 5000 : 500;
const neededCount = extra(Object.keys(this.revElements).length);
const urlParams = new URLSearchParams(location.search);
const dir = urlParams.get('dir');
const offset = urlParams.get('offset');
const isPrev = dir === 'prev';
let currentLimit = Math.min(neededCount, isPrev ? limit : extra(100));
const baseParams = {
action: 'query',
list: 'usercontribs',
ucprop: 'ids|timestamp',
ucuser: this.relevantUser,
format: 'json'
};
if (this.hasNewer || this.hasOlder) {
baseParams.ucdir = isPrev ? 'newer' : 'older';
if (offset && this.hasOlder) {
baseParams.ucstart = offset;
}
}
let later = null;
let requested = 0;
let continueParams = {};
while (requested < neededCount) {
incrementCounter('usercontribs-query');
const params = { ...baseParams, ...continueParams, uclimit: currentLimit };
const data = await this.api.get(params);
const contribs = data?.query?.usercontribs || [];
requested += currentLimit;
for (const contrib of contribs) {
if (contrib.revid) {
if (contrib.revid in this.revElements) {
this.timestamps[contrib.revid] = contrib.timestamp;
}
if (!isPrev && contrib.revid < this.lastRevid && (!later || contrib.revid > later.revid)) {
later = contrib;
}
}
}
if (!data?.continue || requested >= neededCount) break;
continueParams = data.continue;
currentLimit = Math.min(limit, neededCount - requested);
}
if (later) {
this.nextRevid = later.revid;
this.timestamps[later.revid] = later.timestamp;
}
}
hasMissingTimestamps() {
return Object.values(this.timestamps).some(ts => ts === null);
}
async fetchRevisions(revisions) {
let mode;
if (revisions) {
mode = 'revisions';
} else {
mode = 'missing';
await this.userContribsPromise;
revisions = Object.keys(this.timestamps).filter(r => this.timestamps[r] === null);
}
if (!revisions.length) return;
incrementCounter(`revisions-${mode}`);
revisions.unshift(revisions.pop());
const limit = this.rights.apihighlimits ? 500 : 50;
for (let i = 0; i < revisions.length; i += limit) {
incrementCounter(`revisions-${mode}-query`);
const chunk = revisions.slice(i, i + limit);
const data = await this.api.get({
action: 'query',
prop: 'revisions',
revids: chunk.join('|'),
rvprop: 'ids|timestamp',
format: 'json'
});
const pages = data?.query?.pages || {};
for (const page of Object.values(pages)) {
for (const rev of page.revisions || []) {
this.timestamps[rev.revid] = rev.timestamp;
}
}
}
}
async fetchNextRevid(caller) {
incrementCounter(`next-revid-${caller}`)
if (!this.lastRevid || !this.hasOlder) return;
const link = document.querySelector('a.mw-nextlink');
if (!link?.href) return;
const url = new URL(link.href);
if (this.relevantUser) {
const offset = url.searchParams.get('offset');
if (!offset) return;
const params = {
action: 'query',
list: 'usercontribs',
uclimit: 20,
ucstart: offset,
ucprop: 'ids|timestamp',
ucuser: this.relevantUser,
format: 'json',
};
incrementCounter(`next-revid-user-${caller}`)
const data = await this.api.get(params);
const next = data?.query?.usercontribs?.find(c => Number(c.revid) < this.lastRevid);
if (!next) return;
this.nextRevid = next.revid;
this.timestamps[next.revid] = next.timestamp;
} else {
url.searchParams.set('limit', '20');
incrementCounter(`next-revid-nouser-${caller}`)
const response = await fetch(url);
if (!response.ok) return;
const html = await response.text();
const fetched = new DOMParser().parseFromString(html, 'text/html');
const listItems = fetched.querySelectorAll('ul.mw-contributions-list > li[data-mw-revid]');
for (const li of listItems) {
const revid = Number(li.getAttribute('data-mw-revid'));
if (revid && revid < this.lastRevid) {
this.nextRevid = revid;
this.timestamps[revid] = this.extractTimestamp(li);
return;
}
}
}
}
async getTimestamp(revid) {
if (this.timestamps[revid]) {
return this.timestamps[revid];
}
if (revid && revid === this.nextRevid) {
this.nextRevidTimestampPromise ||= this.fetchRevisions([revid]);
await this.nextRevidTimestampPromise;
} else {
await this.timestampsPromise;
}
return this.timestamps[revid];
}
async getNextRevid(caller) {
if (this.nextRevid !== null) {
return this.nextRevid;
}
await this.userContribsPromise;
if (this.nextRevid !== null) {
return this.nextRevid;
}
this.nextRevidPromise ||= this.fetchNextRevid(caller);
await this.nextRevidPromise;
return this.nextRevid;
}
createNoRevid(string) {
return "norev" + (this.noRevids[string] ??= --this.noRevidIndex);
}
}
mw.loader.using(['mediawiki.api', 'mediawiki.storage', 'mediawiki.util', 'mediawiki.DateFormatter']).then(async () => {
const special = mw.config.get('wgCanonicalSpecialPageName');
if (!['Contributions', 'DeletedContributions'].includes(special)) return;
const formatTimeAndDate = mw.loader.require('mediawiki.DateFormatter').formatTimeAndDate;
const relevantUser = mw.config.get('wgRelevantUserName');
const articlePath = mw.config.get('wgArticlePath')?.replace(/\$1$/, '') || '/wiki/';
const scriptPath = mw.config.get('wgScript') || '/w/index.php';
const api = new mw.Api();
const mutex = new Mutex();
const rights = await getRights();
const revisionData = new RevisionData(api, special, relevantUser, rights);
const addedTitles = [];
const contentChanges = new Set();
let showUser;
let toggleButtonDisplayed = false;
incrementCounter('script-run');
addFilterLogCSS();
const buttonContainer = createButtonContainer();
if (!buttonContainer) return;
addToggleButton();
if (relevantUser) {
if (!ensureContributionsList(revisionData)) return;
incrementCounter('mode-single');
showUser = false;
await processUser(relevantUser);
} else {
incrementCounter('mode-multiple');
showUser = true;
const { users, additional } = getMultipleUsers();
const processUsersPromise = processUsers(users);
if (additional.size) {
processAdditional(additional, processUsersPromise, !users.size);
}
}
saveCounters();
async function getRights() {
const siteId = mw.config.get('wgDBname') || 'unknown';
const userId = mw.config.get('wgUserId') || 0;
const rightsKey = `unfiltered-rights-${siteId}-${userId}`;
const cached = mw.storage.getObject(rightsKey);
if (cached) return cached;
incrementCounter('rights');
const data = await api.get({
action: 'query',
meta: 'userinfo',
uiprop: 'rights',
format: 'json'
});
const rightsList = data?.query?.userinfo?.rights || [];
const rights = {
apihighlimits: rightsList.includes('apihighlimits'),
deletedhistory: rightsList.includes('deletedhistory'),
deletedtext: rightsList.includes('deletedtext')
};
mw.storage.setObject(rightsKey, rights, 86400);
return rights;
}
function addFilterLogCSS() {
mw.util.addCSS(`
.abusefilter-container {
display: inline-block;
}
.abusefilter-container::before {
content: "[";
padding-right: 0.1em;
}
.abusefilter-container::after {
content: "]";
padding-left: 0.1em;
}
.abusefilter-logid {
display: inline-block;
}
.abusefilter-logid-tag, .abusefilter-logid-tag > a {
color: var(--color-content-added, #348469);
}
.abusefilter-logid-showcaptcha, .abusefilter-logid-showcaptcha > a {
color: var(--color-content-removed, #d0450b);
}
.abusefilter-logid-warn, .abusefilter-logid-warn > a {
color: var(--color-warning, #957013);
}
.abusefilter-logid-disallow, .abusefilter-logid-disallow > a {
color: var(--color-error, #e90e01);
}
.abusefilter-logid-warned, .abusefilter-logid-warned > a {
text-decoration: underline;
text-decoration-color: var(--color-warning, #957013);
text-decoration-thickness: 1.25px;
text-underline-offset: 1.25px;
}
li.mw-contributions-deleted, li.mw-contributions-no-revision, li.mw-contributions-removed {
background-color: color-mix(in srgb, var(--background-color-destructive, #bf3c2c) 16%, transparent);
margin-bottom: 0;
padding-bottom: 0.1em;
}
.mw-pager-body.hide-unfiltered li.mw-contributions-deleted,
.mw-pager-body.hide-unfiltered li.mw-contributions-no-revision,
.mw-pager-body.hide-unfiltered li.mw-contributions-removed {
display: none;
}
`);
}
function addToggleButton() {
const expandIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="currentColor"><circle cx="2" cy="12" r="1"/><circle cx="6" cy="12" r="1"/><circle cx="10" cy="12" r="1"/><circle cx="14" cy="12" r="1"/><circle cx="18" cy="12" r="1"/><circle cx="22" cy="12" r="1"/></g><path d="M12 9V1M12 1L9 5M12 1L15 5M12 15V23M12 23L9 19M12 23L15 19" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2"/></svg>';
const collapseIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="currentColor"><circle cx="2" cy="1" r="1"/><circle cx="6" cy="1" r="1"/><circle cx="10" cy="1" r="1"/><circle cx="14" cy="1" r="1"/><circle cx="18" cy="1" r="1"/><circle cx="22" cy="1" r="1"/><circle cx="2" cy="23" r="1"/><circle cx="6" cy="23" r="1"/><circle cx="10" cy="23" r="1"/><circle cx="14" cy="23" r="1"/><circle cx="18" cy="23" r="1"/><circle cx="22" cy="23" r="1"/></g><path d="M12 3.25V10.5M12 10.5L9 6.5M12 10.5L15 6.5M12 20.75V13.5M12 13.5L9 17.5M12 13.5L15 17.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2"/></svg>';
const pager = document.querySelector('.mw-pager-body');
if (!pager) return;
const button = createButton('toggle', 'Collapse unfiltered', collapseIcon);
button.style.display = 'none';
buttonContainer.append(button);
button.addEventListener('click', e => {
e.stopPropagation();
const hideUnfiltered = pager.classList.toggle('hide-unfiltered');
button.innerHTML = hideUnfiltered ? expandIcon : collapseIcon;
button.title = hideUnfiltered ? 'Expand unfiltered' : 'Collapse unfiltered';
});
}
function createButtonContainer() {
const form = document.querySelector('.mw-htmlform');
if (!form) return;
const legend = form.querySelector('legend');
if (!legend) return;
legend.style.display = 'flex';
const buttonContainer = document.createElement('div');
buttonContainer.style.marginLeft = 'auto';
buttonContainer.style.display = 'flex';
buttonContainer.style.gap = '12px';
legend.append(buttonContainer);
return buttonContainer;
}
function createButton(name, title, icon) {
const button = document.createElement('button');
button.type = 'button';
button.className = `unfiltered-${name}-button`;
button.title = title;
button.innerHTML = icon;
button.style.cssText = `
background: none;
border: none;
cursor: pointer;
width: 24px;
height: 24px;
padding: 0;
margin-left: auto;
vertical-align: middle;
`;
return button;
}
function ensureContributionsList(revisionData) {
if (!revisionData.lastRevid) {
const pagerBody = document.querySelector('.mw-pager-body');
if (pagerBody && !pagerBody.querySelector('.mw-contributions-list')) {
const ul = document.createElement('ul');
ul.className = 'mw-contributions-list';
pagerBody.append(ul);
} else {
return false;
}
}
return true;
}
function getMultipleUsers() {
const links = document.querySelectorAll('ul.mw-contributions-list li a.mw-anonuserlink');
const users = new Set();
const additional = new Set();
for (const link of links) {
users.add(link.textContent.trim());
}
for (const ip of enumerateSmallIPv4Range(mw.config.get('wgPageName'))) {
if (!users.has(ip)) {
additional.add(ip);
}
}
return { users, additional };
}
function enumerateSmallIPv4Range(input) {
const m = input.match(/^[^\/]+\/((?:1?\d\d?|2[0-4]\d|25[0-5])(?:\.(?:1?\d\d?|2[0-4]\d|25[0-5])){3})\/(2[4-9]|3[0-2])\b/);
if (!m) return [];
const ip = m[1].split('.').reduce((acc, oct) => (acc << 8n) + BigInt(oct), 0n);
const mask = Number(m[2]);
const count = 1n << BigInt(32 - mask);
const base = ip & ~(count - 1n);
return Array.from({ length: Number(count) }, (_, i) => {
const ipValue = base + BigInt(i);
return [
(ipValue >> 24n) & 255n,
(ipValue >> 16n) & 255n,
(ipValue >> 8n) & 255n,
ipValue & 255n,
].join('.');
});
}
async function processUsers(users) {
for (const user of users) {
incrementCounter('mode-multiple-user');
await processUser(user);
}
}
function processAdditional(ips, processUsersPromise, autoClick) {
const processTalkUsersPromise = processTalkUsers(ips, processUsersPromise);
const queryIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/><circle class="query-icon-ring" cx="12" cy="12" r="10" fill="none" stroke="gray" stroke-width="2" stroke-dasharray="62.832" stroke-dashoffset="62.832" transform="rotate(-90 12 12)"/><text class="query-icon-mark" x="12" y="16" text-anchor="middle" font-size="14" fill="currentColor">?</text></svg>';
const button = createButton('query', 'Query additional addresses', queryIcon);
buttonContainer.prepend(button);
let running = false;
button.addEventListener('click', async (e) => {
e.stopPropagation();
if (running) return;
running = true;
button.querySelector('.query-icon-mark').setAttribute('fill', 'gray');
button.title = 'Querying additional addresses';
await processTalkUsersPromise;
const ring = button.querySelector('.query-icon-ring');
let count = 0, circumference = 20 * Math.PI;
for (const ip of ips) {
incrementCounter('mode-multiple-additional');
await processUser(ip);
ring.setAttribute('stroke-dashoffset', ((1 - ++count / ips.size) * circumference));
}
button.title = `Queried ${ips.size} addresses`;
});
if (autoClick && ensureContributionsList(revisionData)) {
button.click();
}
}
async function processTalkUsers(ips, processUsersPromise) {
await processUsersPromise;
const talkTitles = Array.from(ips).map(ip => `User talk:${ip}`);
const existingTalkPages = await getExistingPages(talkTitles);
const talkUsers = existingTalkPages.map(title => title.replace(/^User talk:/, ''));
for (const ip of talkUsers) {
incrementCounter('mode-multiple-talk');
await processUser(ip);
ips.delete(ip);
}
}
async function getExistingPages(titles) {
function batch(items, maxSize) {
const minBins = Math.ceil(items.length / maxSize);
const bins = Array.from({ length: minBins }, () => []);
items.forEach((item, i) => {
bins[i % minBins].push(item);
});
return bins;
}
incrementCounter('existing-title');
const responses = await Promise.all(
batch(titles, 50).map(batchTitles => {
incrementCounter('existing-title-query');
return api.get({
action: 'query',
titles: batchTitles.join('|'),
prop: 'info',
format: 'json',
formatversion: 2
});
})
);
return responses.flatMap(response =>
response.query.pages.filter(page => !page.missing).map(page => page.title)
);
}
async function processUser(user) {
const start = await getStartValue(revisionData);
if (special === 'Contributions') {
const abusePromise = fetchAbuseLog(user, start);
const deletedPromise = rights.deletedhistory ? fetchDeletedRevisions(user, start) : Promise.resolve();
const [remainingHits] = await Promise.all([abusePromise, deletedPromise]);
await updateRevisions(remainingHits, true);
} else {
await fetchAbuseLog(user, start);
}
if (addedTitles.length) {
await updateTitleLinks(addedTitles);
addedTitles.length = 0;
}
if (contentChanges.size) {
mutex.run(() => {
mw.hook('wikipage.content').fire($([...contentChanges]));
contentChanges.clear();
});
}
}
async function getStartValue(revisionData) {
if (!revisionData.hasNewer) {
return null;
}
const urlParams = new URLSearchParams(location.search);
const dirParam = urlParams.get('dir');
const offsetParam = urlParams.get('offset');
if (dirParam !== 'prev' && /^\d{14}$/.test(offsetParam)) {
return offsetParam;
} else if (dirParam === 'prev') {
const iso = await revisionData.getTimestamp(revisionData.firstRevid);
if (iso) {
const date = new Date(iso);
date.setUTCSeconds(date.getUTCSeconds() + 1);
return date.toISOString().replace(/\D/g, '').slice(0, 14);
}
}
return null;
}
async function fetchAbuseLog(user, start) {
function updateWarned(warned) {
for (const [revid, filterText] of warned) {
const li = revisionData.revElements[revid];
if (!li) return;
const filters = li.querySelectorAll('.abusefilter-container .abusefilter-logid');
for (let i = filters.length - 1; i >= 0; i--) {
const filter = filters[i];
if (filter.textContent === filterText) {
filter.classList.add('abusefilter-logid-warned');
break;
}
}
}
}
const limit = rights.apihighlimits ? 250 : 50;
const revisionMap = new Map();
const params = {
action: 'query',
list: 'abuselog',
afllimit: limit,
aflprop: 'ids|filter|user|title|action|result|timestamp|hidden|revid',
afluser: user,
format: 'json',
};
const hits = {};
let excessEntryCount = 0;
do {
incrementCounter('abuselog-query');
const data = await api.get({ ...params, ...(start && { aflstart: start })});
const logs = data?.query?.abuselog || [];
const warned = new Map();
start = data?.continue?.aflstart || null;
for (const entry of logs) {
const revid = entry.revid;
if (revisionData.lastRevid) {
if (revid) {
if (Number(revid) < revisionData.lastRevid) {
excessEntryCount++;
}
} else {
const lastTimestamp = await revisionData.getTimestamp(revisionData.lastRevid);
if (entry.timestamp < lastTimestamp) {
excessEntryCount++;
}
}
} else if (revisionData.hasOlder) {
excessEntryCount++;
}
const warnedKey = `${entry.filter_id}|${entry.filter}|${entry.title}|${entry.user}`;
if (revid) {
revisionMap.set(warnedKey, revid);
} else if (entry.result === 'warn') {
const warnedRevid = revisionMap.get(warnedKey);
if (warnedRevid) {
const filterText = entry.filter_id ?? entry.filter;
warned.set(warnedRevid, filterText);
revisionMap.delete(warnedKey);
}
}
entry.filter_id = entry.filter_id || 'private';
entry.result = entry.result || 'none';
entry.userstring = user;
if (revid) {
entry.revtype = revisionData.revElements[revid] ? 'matched' : 'unmatched';
hits[revid] ??= [];
hits[revid].push(entry);
} else if (special === 'Contributions') {
const editKey = `${entry.timestamp}>${entry.title}>${entry.user}`;
const norevid = revisionData.createNoRevid(editKey);
entry.revtype = 'no-revision';
entry.norevid = norevid;
hits[norevid] ??= [];
hits[norevid].push(entry);
}
}
if (excessEntryCount >= limit) {
start = null;
}
await updateRevisions(hits);
if (warned.size) {
updateWarned(warned);
}
} while (start);
return hits;
}
async function fetchDeletedRevisions(user, start) {
let adrcontinue = null;
do {
const params = {
action: 'query',
list: 'alldeletedrevisions',
adruser: user,
adrprop: 'flags|ids|parsedcomment|size|tags|timestamp|user',
adrlimit: 50,
format: 'json',
};
if (adrcontinue) {
params.adrcontinue = adrcontinue;
}
incrementCounter('deleted-query');
const data = await api.get({ ...params, ...(start && { adrstart: start })});
for (const page of data?.query?.alldeletedrevisions || []) {
for (const entry of page.revisions || []) {
const { tooNew, tooOld } = await checkBounds(entry, 'deleted');
if (!tooNew && !tooOld) {
entry.title = page.title;
entry.userstring = user;
entry.revtype = 'deleted';
const li = createListItem(entry);
insertListItem(li);
}
if (tooOld) return;
}
}
adrcontinue = data?.continue?.adrcontinue || null;
} while (adrcontinue);
}
async function checkBounds(entry, type) {
const { hasNewer, hasOlder, firstRevid, lastRevid } = revisionData;
const hasRevid = Boolean(entry.revid);
const entryValue = hasRevid ? Number(entry.revid) : entry.timestamp;
const getDataValue = hasRevid
? id => Number(id)
: async id => await revisionData.getTimestamp(id);
let tooNew = false;
let tooOld = false;
if (hasNewer && firstRevid) {
const firstValue = await getDataValue(firstRevid);
if (firstValue && entryValue > firstValue) {
tooNew = true;
}
}
if (!tooNew && hasOlder && lastRevid) {
const lastValue = await getDataValue(lastRevid);
if (lastValue && entryValue <= lastValue) {
const nextRevid = await revisionData.getNextRevid(type);
if (nextRevid) {
const nextValue = await getDataValue(nextRevid);
if (nextValue && entryValue <= nextValue) {
tooOld = true;
}
}
}
}
return { tooNew, tooOld };
}
async function updateRevisions(hits, finalUpdate = false) {
const matched = [];
for (const revid in hits) {
let li = revisionData.revElements[revid] || revisionData.noRevElements[revid];
if (!li && (revid.startsWith('norev') || finalUpdate)) {
const first = hits[revid][0];
const { tooNew, tooOld } = await checkBounds(first, first.revtype);
if (!tooNew && !tooOld) {
if (first.revtype === 'unmatched') first.revtype = 'removed';
li = createListItem(first);
insertListItem(li);
}
}
if (!li) continue;
let container = li.querySelector('.abusefilter-container');
if (!container) {
container = document.createElement('span');
container.className = 'abusefilter-container';
li.append(document.createTextNode(' '), container);
}
for (const entry of hits[revid]) {
if (container.firstChild) {
container.prepend(document.createTextNode(' '));
}
container.prepend(createFilterElement(entry));
}
matched.push(revid);
contentChanges.add(li);
}
for (const revid of matched) {
delete hits[revid];
}
}
async function updateTitleLinks(links) {
const titleToLinks = {};
for (const link of links) {
(titleToLinks[link.title] ||= []).push(link);
}
const existingPages = new Set(await getExistingPages(Object.keys(titleToLinks)));
for (const [title, linkGroup] of Object.entries(titleToLinks)) {
const isMissing = !existingPages.has(title);
for (const link of linkGroup) {
if (isMissing) {
const url = new URL(link.href);
if (url.pathname.startsWith(articlePath)) {
url.search = `?title=${url.pathname.slice(articlePath.length)}&action=edit&redlink=1`;
url.pathname = scriptPath;
link.href = url.toString();
link.classList.add('new');
link.title += ' (page does not exist)';
}
} else {
link.classList.remove('new');
}
}
}
}
function insertListItem(li) {
return mutex.run(() => insertListItemUnsafe(li));
}
async function insertListItemUnsafe(li) {
if (!toggleButtonDisplayed) {
const button = document.querySelector('.unfiltered-toggle-button');
if (button) {
toggleButtonDisplayed = true;
button.style.display = '';
}
}
const allLis = Array.from(document.querySelectorAll('ul.mw-contributions-list > li'));
const newRevid = li.getAttribute('data-revid');
for (const existingLi of allLis) {
const revid = existingLi.getAttribute('data-mw-revid') || existingLi.getAttribute('data-revid');
if (newRevid && revid && Number(newRevid) > Number(revid)) {
existingLi.parentElement.insertBefore(li, existingLi);
return;
}
const dataTimestamp = existingLi.getAttribute('data-timestamp');
const ts = dataTimestamp ?? (revid ? await revisionData.getTimestamp(revid) : null);
if (!ts) return;
const newTimestamp = li.getAttribute('data-timestamp');
if (newTimestamp > ts) {
existingLi.parentElement.insertBefore(li, existingLi);
return;
}
}
const lastUl = document.querySelectorAll('ul.mw-contributions-list');
if (lastUl.length) {
lastUl[lastUl.length - 1]?.append(li);
}
}
function createFilterElement(entry) {
const element = document.createElement('span');
element.className = `abusefilter-logid abusefilter-logid-${entry.result}`;
element.title = entry.filter;
if (entry.filter_id !== 'private') {
const link = document.createElement('a');
link.href = `${articlePath}Special:AbuseLog/${entry.id}`;
link.textContent = entry.filter_id;
element.append(link);
} else {
element.textContent = 'private';
}
return element;
}
function createListItem(entry) {
const revTypeLabel = {
'deleted': 'Deleted',
'no-revision': 'No revision',
'removed': 'Removed'
};
const li = document.createElement('li');
li.className = `mw-contributions-${entry.revtype}`;
if (entry.revid) {
li.setAttribute('data-revid', entry.revid);
} else {
li.setAttribute('data-norevid', entry.norevid);
}
li.setAttribute('data-timestamp', entry.timestamp);
const pageTitleEncoded = mw.util.wikiUrlencode(entry.title);
const formattedTimestamp = formatTimeAndDate(new Date(entry.timestamp));
let timestamp;
if (entry.revtype === 'deleted' && rights.deletedtext) {
const ts = new Date(entry.timestamp).toISOString().replace(/\D/g, '').slice(0, 14);
timestamp = document.createElement('a');
timestamp.className = 'mw-changeslist-date';
timestamp.href = `${scriptPath}?title=Special:Undelete&target=${pageTitleEncoded}×tamp=${ts}`;
timestamp.title = 'Special:Undelete';
timestamp.textContent = formattedTimestamp;
} else {
timestamp = document.createElement('span');
timestamp.className = 'mw-changeslist-date';
timestamp.textContent = formattedTimestamp;
}
const titleSpanWrapper = document.createElement('span');
titleSpanWrapper.className = 'mw-title';
const titleBdi = document.createElement('bdi');
titleBdi.setAttribute('dir', 'ltr');
const titleLink = document.createElement('a');
titleLink.textContent = entry.title;
titleLink.href = `${articlePath}${pageTitleEncoded}`;
if (entry.revtype === 'deleted') {
titleLink.className = 'mw-contributions-title new';
} else {
titleLink.className = 'mw-contributions-title';
}
titleLink.title = entry.title;
titleBdi.append(titleLink);
titleSpanWrapper.append(titleBdi);
li.append(timestamp, ' ');
const sep1 = document.createElement('span');
sep1.className = 'mw-changeslist-separator';
li.append(sep1, ' ');
const label = document.createElement('span');
label.textContent = revTypeLabel[entry.revtype] || entry.revtype;
label.style.fontStyle = 'italic';
li.append(label, ' ');
const sep2 = document.createElement('span');
sep2.className = 'mw-changeslist-separator';
li.append(sep2, ' ');
if (entry.revtype === 'deleted') {
if (entry.minor !== undefined) {
const minorAbbr = document.createElement('abbr');
minorAbbr.className = 'minoredit';
minorAbbr.title = 'This is a minor edit';
minorAbbr.textContent = 'm';
li.append(' ', minorAbbr, ' ');
}
if (entry.parentid === 0) {
const newAbbr = document.createElement('abbr');
newAbbr.className = 'newpage';
newAbbr.title = 'This edit created a new page';
newAbbr.textContent = 'N';
li.append(' ', newAbbr, ' ');
}
}
li.append(titleSpanWrapper);
if (showUser && entry.user) {
li.append(' ');
const sep3 = document.createElement('span');
sep3.className = 'mw-changeslist-separator';
li.append(sep3, ' ');
const userBdi = document.createElement('bdi');
userBdi.setAttribute('dir', 'ltr');
userBdi.className = 'mw-userlink mw-anonuserlink';
const userLink = document.createElement('a');
const userEncoded = mw.util.wikiUrlencode(entry.user);
userLink.href = `${articlePath}Special:Contributions/${userEncoded}`;
userLink.className = 'mw-userlink mw-anonuserlink';
userLink.title = `Special:Contributions/${entry.user}`;
userLink.textContent = entry.userstring || entry.user;
userBdi.append(userLink);
li.append(userBdi);
}
if (entry.revtype === 'deleted' && entry.parsedcomment) {
const commentSpan = document.createElement('span');
commentSpan.className = 'comment';
commentSpan.innerHTML = `(${entry.parsedcomment})`;
li.append(' ', commentSpan);
}
if (entry.revid) {
revisionData.revElements[entry.revid] = li;
} else {
revisionData.noRevElements[entry.norevid] = li;
}
addedTitles.push(titleLink);
contentChanges.add(li);
return li;
}
});