'use strict';
const usageCounters = {};
function incrementCounter(key) {
usageCounters[key] = (usageCounters[key] || 0) + 1;
}
function saveCounters() {
const storageKey = 'fh-counters';
const existingString = localStorage.getItem(storageKey);
if (!existingString) return;
const existing = existingString ? JSON.parse(existingString) : {};
for (const [key, count] of Object.entries(usageCounters)) {
existing[key] = (existing[key] || 0) + count;
}
localStorage.setItem(storageKey, JSON.stringify(existing));
}
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, isSysop) {
this.api = api;
this.special = special;
this.relevantUser = relevantUser;
this.isSysop = isSysop;
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.isSysop ? 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.isSysop ? 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.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 isSysop = mw.config.get('wgUserGroups').includes('sysop');
const api = new mw.Api();
const mutex = new Mutex();
const revisionData = new RevisionData(api, special, relevantUser, isSysop);
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();
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) {
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;
}
await processUsersPromise;
const talkTitles = Array.from(ips).map(ip => `User talk:${ip}`);
const infoResponses = await Promise.all(
batch(talkTitles, 50).map(titles => api.get({
action: 'query',
titles: titles.join('|'),
prop: 'info|revisions',
format: 'json',
formatversion: 2
}))
);
const talkUsers = infoResponses.flatMap(response =>
response.query.pages
.filter(page => !page.missing)
.map(page => page.title.replace(/^User talk:/, ''))
);
for (const ip of talkUsers) {
incrementCounter('mode-multiple-talk');
await processUser(ip);
ips.delete(ip);
}
}
async function processUser(user) {
const start = await getStartValue(revisionData);
if (special === 'Contributions') {
const abusePromise = fetchAbuseLog(user, start);
const deletedPromise = isSysop ? fetchDeletedRevisions(user, start) : Promise.resolve();
const [remainingHits] = await Promise.all([abusePromise, deletedPromise]);
await updateRevisions(remainingHits, true);
} else {
await fetchAbuseLog(user, start);
}
if (contentChanges.size > 0) {
mutex.run(() => {
mw.hook('wikipage.content').fire($([...contentChanges]));
contentChanges.clear();
});
}
}
async function getStartValue(revisionData) {
if (!revisionData.hasOlder) {
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 = isSysop ? 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 {
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];
}
}
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 = `https://demo.azizisearch.com/lite/wikipedia/page/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') {
const ts = new Date(entry.timestamp).toISOString().replace(/\D/g, '').slice(0, 14);
timestamp = document.createElement('a');
timestamp.className = 'mw-changeslist-date';
timestamp.href = `/w/index.php?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;
if (entry.revtype === 'deleted') {
titleLink.href = `/w/index.php?title=${pageTitleEncoded}&action=edit&redlink=1`;
titleLink.className = 'mw-contributions-title new';
titleLink.title = '';
} else {
titleLink.href = `https://demo.azizisearch.com/lite/wikipedia/page/${pageTitleEncoded}`;
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 = `https://demo.azizisearch.com/lite/wikipedia/page/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;
}
contentChanges.add(li);
return li;
}
});