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 code will be executed when previewing this page.
This user script seems to have a documentation page at User:Daniel Quinlan/Scripts/Unfiltered and an accompanying .json page at User:Daniel Quinlan/Scripts/Unfiltered.json.
'use strict';
mw.loader.using(['mediawiki.api', 'mediawiki.language', 'mediawiki.storage', 'mediawiki.user', 'mediawiki.util', 'mediawiki.DateFormatter', 'mediawiki.ForeignApi']).then(async () => {
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(context, api, rights) {
this.context = context;
this.api = api;
this.rights = rights;
this.revElements = {};
this.noRevElements = {};
this.timestamps = {};
}
addRevid(revid, element, timestamp) {
this.revElements[revid] = element;
this.timestamps[revid] = timestamp;
if (!this.firstRevid || revid > this.firstRevid) this.firstRevid = revid;
if (!this.lastRevid || revid < this.lastRevid) this.lastRevid = revid;
}
extractDeletedTimestamp(element) {
const link = element.querySelector('a[href*="Special:Undelete"]:where([href*="×tamp="],[href*="?timestamp="])');
if (!link) return null;
const match = link.href.match(/[&?]timestamp=(\d{14})(?:&|$)/);
if (!match) return null;
return mwToIso(match[1]);
}
}
class DiffData extends RevisionData {
constructor(context) {
super(context);
for (const td of document.querySelectorAll('.diff-otitle, .diff-ntitle')) {
const revid = this.getRevid(td);
if (!revid) continue;
const lastDiv = Array.from(td.querySelectorAll(':scope > div'))
.filter(d => !d.querySelector('#differences-prevlink, #differences-nextlink'))
.at(-1);
if (!lastDiv) continue;
if (context.deleted && lastDiv.lastChild?.nodeName === 'BR') {
lastDiv.removeChild(lastDiv.lastChild);
}
this.addRevid(revid, lastDiv, this.extractTimestamp(td));
}
}
getRevid(td) {
return Number(td.querySelector('[data-mw-revid]')?.getAttribute('data-mw-revid'));
}
extractTimestamp(title) {
if (this.context.deleted) return this.extractDeletedTimestamp(title);
return title.querySelector('.mw-diff-timestamp')?.getAttribute('data-timestamp');
}
async getTimestamp(revid) {
return this.timestamps[revid];
}
}
class ListingData extends RevisionData {
constructor(context, api, rights) {
super(context, api, rights);
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 selector = this.context.type === 'page' && this.context.deleted
? 'ul.mw-undelete-revlist > li'
: 'ul.mw-contributions-list > li[data-mw-revid]';
for (const li of document.querySelectorAll(selector)) {
const revid = this.getRevid(li);
if (!revid) continue;
this.addRevid(revid, li, this.extractTimestamp(li));
}
this.fetchTimestampsPromise = this.fetchTimestamps();
this.fetchRevisionsPromise = 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)}`;
}
getRevid(li) {
return Number(li.getAttribute('data-mw-revid') || li.querySelector('[data-mw-revid]')?.getAttribute('data-mw-revid'));
}
extractTimestamp(li) {
if (this.context.deleted) return this.extractDeletedTimestamp(li);
if (this.isoTimezone) return this.extractVisibleTimestamp(li);
return null;
}
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 fetchTimestamps() {
if (this.context.deleted) return;
if (!this.context.user && !this.context.page) return;
if (!this.hasMissingTimestamps()) return;
const extra = n => n + Math.ceil(Math.log10(n / 10 + 1)) + 1;
const limit = this.rights.apihighlimits ? 5000 : 500;
const neededCount = extra(Object.keys(this.revElements).length);
let currentLimit = Math.min(limit, neededCount);
const { baseParams, paramPrefix } = this.buildChangesParams(currentLimit);
let isPrev = false;
if (this.context.page) {
if (this.firstRevid) {
baseParams.rvstartid = this.firstRevid;
baseParams.rvdir = 'older';
}
} else {
const urlParams = new URLSearchParams(location.search);
const dir = urlParams.get('dir');
const offset = urlParams.get('offset');
isPrev = dir === 'prev';
if (this.hasNewer || this.hasOlder) {
baseParams[`${paramPrefix}dir`] = isPrev ? 'newer' : 'older';
if (offset && this.hasOlder) {
baseParams[`${paramPrefix}start`] = offset;
}
}
}
let later = null;
let received = 0;
let continueToken = null;
do {
const params = {
...baseParams,
...(continueToken || {}),
[`${paramPrefix}limit`]: currentLimit
};
const data = await this.api.get(params);
const revisions = this.extractRevisions(data);
if (!revisions.length) break;
received += revisions.length;
for (const rev of revisions) {
if (rev.revid) {
if (rev.revid in this.revElements) {
this.timestamps[rev.revid] = rev.timestamp;
}
if (!isPrev && rev.revid < this.lastRevid && (!later || rev.revid > later.revid)) {
later = rev;
}
}
}
if (received >= neededCount) break;
continueToken = data?.continue;
currentLimit = Math.min(limit, neededCount - received);
} while (continueToken);
if (later) {
this.nextRevid = later.revid;
this.timestamps[later.revid] = later.timestamp;
}
}
hasMissingTimestamps() {
return Object.values(this.timestamps).some(ts => ts === null);
}
buildChangesParams(limit) {
const paramPrefix = this.context.type === 'page' ? 'rv' : 'uc';
const baseParams = {
action: 'query',
[`${paramPrefix}limit`]: limit,
[`${paramPrefix}prop`]: 'ids|timestamp',
format: 'json'
};
if (this.context.type === 'page') {
baseParams.prop = 'revisions';
baseParams.titles = this.context.page;
baseParams.rvslots = 'main';
} else {
baseParams.list = 'usercontribs';
baseParams.ucuser = this.context.user;
}
return { baseParams, paramPrefix };
}
extractRevisions(data) {
return this.context.type === 'page'
? Object.values(data?.query?.pages || {})[0]?.revisions || []
: data?.query?.usercontribs || [];
}
async fetchRevisions(revisions) {
if (this.context.deleted) return;
let mode;
if (revisions) {
mode = 'revisions';
} else {
mode = 'missing';
await this.fetchTimestampsPromise;
revisions = Object.keys(this.timestamps).filter(r => this.timestamps[r] === null);
}
if (!revisions.length) return;
revisions.unshift(revisions.pop());
const limit = this.rights.apihighlimits ? 500 : 50;
for (let i = 0; i < revisions.length; i += limit) {
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'
});
for (const page of Object.values(data?.query?.pages || {})) {
for (const rev of page.revisions || []) {
this.timestamps[rev.revid] = rev.timestamp;
}
}
}
}
async fetchNextRevid(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.context.page || this.context.user) {
const { baseParams, paramPrefix } = this.buildChangesParams(20);
if (this.context.page) {
baseParams.rvstartid = this.lastRevid;
} else {
const offset = url.searchParams.get('offset');
if (!offset) return;
baseParams[`${paramPrefix}start`] = offset;
}
const data = await this.api.get(baseParams);
const revisions = this.extractRevisions(data);
const next = revisions.find(rev => Number(rev.revid) < this.lastRevid);
if (next) {
this.nextRevid = next.revid;
this.timestamps[next.revid] = next.timestamp;
return;
}
}
url.searchParams.set('limit', '20');
const response = await fetch(url);
if (!response.ok) return;
const html = await response.text();
const fetched = new DOMParser().parseFromString(html, 'text/html');
const items = fetched.querySelectorAll('ul.mw-contributions-list > li[data-mw-revid]');
for (const li of items) {
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.fetchRevisionsPromise;
}
return this.timestamps[revid];
}
async getNextRevid(caller) {
if (this.nextRevid) return this.nextRevid;
await this.fetchTimestampsPromise;
if (this.nextRevid) return this.nextRevid;
this.nextRevidPromise ||= this.fetchNextRevid(caller);
await this.nextRevidPromise;
return this.nextRevid;
}
createNoRevid(string) {
return 'norev' + (this.noRevids[string] ??= --this.noRevidIndex);
}
}
class RecentData extends RevisionData {
constructor(context) {
super(context);
this.pages = new Map();
const now = new Date().toISOString();
for (const li of document.querySelectorAll('li.mw-changeslist-line[data-mw-revid]:not(.unfiltered-processed)')) {
const revid = li.getAttribute('data-mw-revid');
if (!revid) continue;
const ts = mwToIso(li.getAttribute('data-mw-ts'));
this.addRevid(revid, li, ts);
li.classList.add('unfiltered-processed');
li.setAttribute('processed', now);
if (context.type === 'wl') {
const pageElement = li.querySelector('[data-target-page]');
if (pageElement) {
const page = pageElement.getAttribute('data-target-page');
if (!this.pages.has(page)) {
this.pages.set(page, ts);
}
}
}
}
}
async getTimestamp(revid) {
return this.timestamps[revid];
}
}
// get context
const context = getContext();
if (!context) return;
// constants
const ACTION_API = 'https://en.wikipedia.org/w/api.php';
const UNFILTERED_JSON = 'User:Daniel Quinlan/Scripts/Unfiltered.json';
const ABUSELOG_BUFFER = 30;
const DEFAULT_OPTIONS = { user: true, page: true };
const DEFAULT_ALIASES = {
'abuselog': 'AbuseLog',
'contributions': 'Contributions',
'log': 'Log',
'undelete': 'Undelete',
};
const DEFAULT_MESSAGES = {
'abusefilter-hidden': 'Private',
'cur': 'cur',
'diff': 'diff',
'hide': 'Hide',
'hist': 'hist',
'last': 'prev',
'log-name-abusefilterblockeddomainhit': 'Blocked domains hit log',
'log-name-spamblacklist': 'Spam block list log',
'minoreditletter': 'm',
'newpage': 'New page',
'newpageletter': 'N',
'recentchanges-label-minor': 'This is a minor edit',
'red-link-title': '$1 (page does not exist)',
'show': 'Show',
};
const INTERNAL_STRINGS = {
'query-addresses': 'Query additional addresses',
'revision-deleted': 'Deleted',
'revision-no-revision': 'No revision',
'revision-removed': 'Removed',
};
// main processing
const formatTimeAndDate = mw.loader.require('mediawiki.DateFormatter').formatTimeAndDate;
const articlePath = mw.config.get('wgArticlePath')?.replace(/\$1$/, '') || '/wiki/';
const scriptPath = mw.config.get('wgScript') || '/w/index.php';
const userLanguage = mw.config.get('wgUserLanguage') || 'en';
const specialName = mw.config.get('wgFormattedNamespaces')?.['-1'] || 'Special';
const api = new mw.Api();
const mutex = new Mutex();
const rights = await getRights();
const strings = await getStrings();
const addedTitles = [];
const contentChanges = new Set();
const includeFields = new Set();
let revisionData;
let firstInsert = false;
addFilterLogCSS();
if (context.type === 'page' || context.type === 'user') {
revisionData = new ListingData(context, api, rights);
if (context.page) {
if (!ensureContributionsList(revisionData)) return;
includeFields.add('user');
await processTarget(context.page, 'page');
} else if (context.user) {
if (!ensureContributionsList(revisionData)) return;
includeFields.add('title');
await processTarget(context.user);
} else {
includeFields.add('title');
includeFields.add('user');
const { users, additional } = getMultipleUsers();
const processUsersPromise = processUsers(users);
if (additional.size) {
processAdditional(additional, processUsersPromise, !users.size);
}
}
} else if (context.type === 'diff') {
await processDiff();
} else if (context.type === 'rc' || context.type === 'wl') {
await processRecent();
const container = document.querySelector('.mw-changeslist');
if (!container) return;
const recentMutex = new Mutex();
new MutationObserver(() => recentMutex.run(() => processRecent()))
.observe(container, { childList: true });
}
function getContext() {
const special = mw.config.get('wgCanonicalSpecialPageName');
const deleted = special === 'DeletedContributions' || special === 'Undelete';
if (special === 'Contributions' || special === 'DeletedContributions') {
return { type: 'user', deleted, user: mw.config.get('wgRelevantUserName') };
}
const page = mw.config.get('wgRelevantPageName')?.replace(/_/g, ' ');
if (!page) return null;
if (mw.config.get('wgAction') === 'history' || (special === 'Undelete' && document.querySelector('ul.mw-undelete-revlist'))) {
return { type: 'page', deleted, page };
}
if ((mw.config.get('wgDiffNewId') && mw.config.get('wgDiffOldId')) || (special === 'Undelete' && document.querySelector('table.diff'))) {
return { type: 'diff', deleted, page };
}
if (special === 'Recentchanges') {
return { type: 'rc', deleted };
}
if (special === 'Watchlist') {
return { type: 'wl', deleted };
}
return null;
}
async function getRights() {
const siteId = mw.config.get('wgWikiID') || 'unknown';
const groups = (mw.config.get('wgUserGroups') || []).sort().join('|');
const rightsKey = `unfiltered-rights-${siteId}-${groups}`;
const cached = mw.storage.getObject(rightsKey);
if (cached) return cached;
const userRights = await mw.user.getRights();
const wanted = ['apihighlimits', 'block', 'deletedhistory', 'deletedtext'];
const rights = Object.fromEntries(wanted.map(r => [r, userRights.includes(r)]));
mw.storage.setObject(rightsKey, rights, 86400);
return rights;
}
async function getStrings() {
const siteId = mw.config.get('wgWikiID') || 'unknown';
const stringsKey = `unfiltered-strings-${siteId}-${userLanguage}`;
const cached = mw.storage.getObject(stringsKey);
const defaults = { ...DEFAULT_ALIASES, ...DEFAULT_MESSAGES, ...INTERNAL_STRINGS };
if (cached) return { ...defaults, ...cached };
const strings = {};
const aliasesPromise = loadAliases();
const languageChain = mw.language.getFallbackLanguageChain();
try {
const [loaded] = await Promise.all([
loadTranslations(),
api.loadMessages(Object.keys(DEFAULT_MESSAGES))
]);
for (const key of Object.keys(defaults)) {
let replacement;
if (DEFAULT_MESSAGES[key]) {
const msg = mw.message(key);
if (msg.exists()) {
replacement = msg.plain();
}
}
for (const language of languageChain) {
const value = loaded[language]?.[key];
if (value) {
replacement = value;
break;
}
}
if (replacement) {
strings[key] = replacement;
}
}
} catch (error) {
console.warn('Unfiltered: getStrings error', error);
}
const aliases = await aliasesPromise;
Object.assign(strings, aliases);
mw.storage.setObject(stringsKey, strings, 86400);
return { ...defaults, ...strings };
}
async function loadAliases() {
const aliases = { ...DEFAULT_ALIASES };
try {
const response = await api.get({
action: 'query',
format: 'json',
formatversion: '2',
meta: 'siteinfo',
siprop: 'specialpagealiases'
});
const result = response?.query?.specialpagealiases || [];
for (const [key, value] of Object.entries(DEFAULT_ALIASES)) {
const found = result.find(item => item.realname === value);
aliases[key] = found?.aliases?.[0] || value;
}
} catch (error) {
console.warn('Unfiltered: loadAliases error', error);
}
return aliases;
}
async function loadTranslations() {
const api = new mw.ForeignApi(ACTION_API);
try {
const data = await api.get({
action: 'query',
format: 'json',
formatversion: 2,
titles: UNFILTERED_JSON,
prop: 'revisions',
rvprop: 'content'
});
const content = data?.query?.pages?.[0]?.revisions?.[0]?.content;
if (content) {
return JSON.parse(content) || {};
}
} catch (error) {
console.warn('Unfiltered: loadTranslations error', error);
}
return {};
}
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-content-text.unfiltered-hide li.mw-contributions-deleted,
#mw-content-text.unfiltered-hide li.mw-contributions-no-revision,
#mw-content-text.unfiltered-hide li.mw-contributions-removed {
display: none;
}
.unfiltered-toggle-button {
color: var(--color-subtle, gray);
}
.unfiltered-toggle-button:hover, .unfiltered-toggle-button:focus-visible {
color: var(--color-base--hover, gray);
}
.unfiltered-icon-hide { display: inline; }
.unfiltered-icon-show { display: none; }
#mw-content-text.unfiltered-hide .unfiltered-icon-hide { display: none; }
#mw-content-text.unfiltered-hide .unfiltered-icon-show { display: inline; }
`);
}
async function addToggleButton() {
const container = buttonContainer();
if (!container) return;
const hideIcon = `<svg xmlns="http://www.w3.org/2000/svg" class="unfiltered-icon-hide" width="24" height="24" viewBox="0 0 24 24"><title>${strings.hide}</title><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 showIcon = `<svg xmlns="http://www.w3.org/2000/svg" class="unfiltered-icon-show" width="24" height="24" viewBox="0 0 24 24"><title>${strings.show}</title><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 text = document.querySelector('#mw-content-text');
if (!text) return;
const options = await getOptions();
if (!options[context.type]) text.classList.add('unfiltered-hide');
const button = createButton('toggle', hideIcon, showIcon);
container.append(button);
let press = null;
const clearTimer = () => {
clearTimeout(press);
press = null;
};
const toggleOption = async () => {
clearTimer();
options[context.type] = !options[context.type];
const contextName = context.type === 'user' ? 'Contributions' : 'History';
mw.notify(`${options[context.type] ? strings.hide : strings.show} \u2192 ${options[context.type] ? strings.show : strings.hide}`, { title: 'Unfiltered', type: 'success' });
await saveOptions(options);
};
const toggleView = () => {
clearTimer();
text.classList.toggle('unfiltered-hide');
};
button.addEventListener('click', e => e.stopPropagation());
button.addEventListener('pointerdown', e => {
e.stopPropagation();
press = setTimeout(toggleOption, 750);
});
button.addEventListener('pointerup', e => {
e.stopPropagation();
if (press) toggleView();
});
button.addEventListener('pointercancel', clearTimer);
button.addEventListener('pointerleave', clearTimer);
button.addEventListener('keydown', async e => {
e.stopPropagation();
if (e.key === 'Enter') toggleView();
else if (e.key === ' ') await toggleOption();
});
}
function buttonContainer() {
if (context.deleted) return null;
if (buttonContainer._element !== undefined) return buttonContainer._element;
buttonContainer._element = null;
const form = document.querySelector('.mw-htmlform');
if (!form) return null;
const legend = form.querySelector('legend');
if (!legend) return null;
legend.style.display = 'flex';
const div = document.createElement('div');
div.className = 'unfiltered-button-container';
div.style.marginLeft = 'auto';
div.style.display = 'flex';
div.style.gap = '12px';
legend.append(div);
buttonContainer._element = div;
return div;
}
function createButton(name, ...icons) {
const button = document.createElement('button');
button.type = 'button';
button.className = `unfiltered-${name}-button`;
button.innerHTML = icons.join('');
button.style.cssText = `
background: none;
border: none;
cursor: pointer;
width: 24px;
height: 24px;
padding: 0;
margin-left: auto;
vertical-align: middle;
`;
return button;
}
async function getOptions() {
try {
const stored = mw.user.options.get('userjs-unfiltered') || '{}';
return { ...DEFAULT_OPTIONS, ...JSON.parse(stored) };
} catch (error) {
console.error('Unfiltered: getOptions error', error);
await saveOptions(DEFAULT_OPTIONS);
return DEFAULT_OPTIONS;
}
}
async function saveOptions(options) {
try {
const value = JSON.stringify(options);
if (mw.user.isNamed()) await api.saveOption('userjs-unfiltered', value);
mw.user.options.set('userjs-unfiltered', value);
} catch (error) {
console.error('Unfiltered: saveOptions error', error);
throw error;
}
}
function ensureContributionsList(revisionData) {
if (!revisionData.lastRevid) {
if (context.deleted) return false;
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) {
await processTarget(user);
}
}
function processAdditional(ips, processUsersPromise, autoClick) {
const container = buttonContainer();
if (!container) return;
const processTalkUsersPromise = processTalkUsers(ips, processUsersPromise);
const queryIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>${strings['query-addresses']}</title><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', queryIcon);
container.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');
await processTalkUsersPromise;
const ring = button.querySelector('.query-icon-ring');
let count = 0, circumference = 20 * Math.PI;
for (const ip of ips) {
await processTarget(ip);
ring.setAttribute('stroke-dashoffset', ((1 - ++count / ips.size) * circumference));
}
});
if (autoClick && ensureContributionsList(revisionData)) {
button.click();
}
}
async function processTalkUsers(ips, processUsersPromise) {
await processUsersPromise;
const userTalkName = mw.config.get('wgFormattedNamespaces')?.[3] || 'User talk';
const talkTitles = Array.from(ips).map(ip => `${userTalkName}:${ip}`);
const existingTalkPages = await getExistingPages(talkTitles);
const talkUsers = existingTalkPages.map(title => title.startsWith(userTalkName + ':') ? title.slice(userTalkName.length + 1) : title);
for (const ip of talkUsers) {
await processTarget(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;
}
const responses = await Promise.all(
batch(titles, 50).map(batchTitles =>
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 processDiff() {
revisionData = new DiffData(context);
const timestamps = Object.values(revisionData.timestamps);
if (!timestamps.length) return;
timestamps.sort();
const start = offsetIso(timestamps.at(-1), ABUSELOG_BUFFER);
const end = offsetIso(timestamps.at(0), -86400);
try {
await fetchHistory(context.page, 'page', start, end);
} catch (error) {
console.error('Unfiltered: processDiff error', error);
}
}
async function processTarget(target, type = 'user') {
const start = await getStartValue(revisionData);
await fetchHistory(target, type, 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 processRecent() {
revisionData = new RecentData(context);
if (!revisionData.firstRevid) return;
try {
if (context.type === 'wl') {
for (const [page, timestamp] of revisionData.pages) {
const start = offsetIso(timestamp, ABUSELOG_BUFFER);
const end = offsetIso(timestamp, -86400);
await fetchHistory(page, 'page', start, end);
}
} else {
const timestamps = Object.values(revisionData.timestamps);
if (!timestamps.length) return;
timestamps.sort();
const start = offsetIso(timestamps.at(-1), ABUSELOG_BUFFER);
await fetchHistory(null, null, start);
}
} catch (error) {
console.error('Unfiltered: processRecent error', error);
}
}
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 offsetIso(mwToIso(offsetParam), ABUSELOG_BUFFER);
} else if (dirParam === 'prev') {
const ts = await revisionData.getTimestamp(revisionData.firstRevid);
if (ts) return offsetIso(ts, ABUSELOG_BUFFER);
}
return null;
}
async function fetchHistory(target, type, start, end) {
const hits = {};
const revisionMap = new Map();
const warned = new Map();
const generators = [];
if (context.type === 'wl') {
generators.push(cachedAbuseLogGenerator(target, type, start, end));
} else {
generators.push(abuseLogGenerator(target, type, start, end));
}
if (context.type === 'page' || context.type === 'user') {
generators.push(
logGenerator('spamblacklist', target, type, start),
logGenerator('abusefilterblockeddomainhit', target, type, start)
);
if (!context.deleted && rights.deletedhistory) {
generators.push(deletedRevisionsGenerator(target, type, start));
}
}
let lastBatchId = null;
for await (const entry of mergeGenerators(...generators)) {
if (entry.revtype === 'deleted') {
insertItem(createItem(entry));
continue;
}
if (entry.batch_id !== lastBatchId) {
await updateRevisions(hits);
if (warned.size) updateWarned(warned);
}
lastBatchId = entry.batch_id;
const revid = entry.revid;
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';
if (revid) {
entry.revtype = revisionData.revElements[revid] ? 'matched' : 'unmatched';
} else if ((context.type === 'page' || context.type === 'user') && !context.deleted) {
const editKey = `${entry.timestamp}>${entry.title}>${entry.user}`;
entry.norevid = revisionData.createNoRevid(editKey);
entry.revtype = 'no-revision';
}
const key = entry.revid || entry.norevid;
if (key) {
hits[key] ??= [];
hits[key].push(entry);
}
}
await updateRevisions(hits, true);
if (warned.size) updateWarned(warned);
}
async function* abuseLogGenerator(target, type, start, end) {
const revisionCount = Object.keys(revisionData.revElements).length;
let extra = revisionCount;
if (context.type === 'diff' || context.type === 'rc' || context.type === 'wl') extra = 0;
const limit = Math.min(50 + extra, 250);
const excessLimit = revisionCount <= 50 ? 25 : 50;
let excessCount = 0;
const params = {
action: 'query',
list: 'abuselog',
afllimit: limit,
aflprop: 'ids|filter|user|title|action|result|timestamp|hidden|revid',
format: 'json'
};
if (start) params.aflstart = start;
if (end) params.aflend = end;
if (type === 'user') params.afluser = target;
else if (type === 'page') params.afltitle = target;
let continueToken = null;
do {
const batchId = Math.random();
const data = await api.get({ ...params, ...(continueToken || {}) });
if (data?.error) {
console.error('Unfiltered: abuseLogGenerator error', data.error);
break;
}
for (const entry of data?.query?.abuselog || []) {
const revid = entry.revid;
if (revisionData.lastRevid) {
if (revid) {
if (Number(revid) < revisionData.lastRevid) excessCount++;
} else {
const lastTime = await revisionData.getTimestamp(revisionData.lastRevid);
if (entry.timestamp < lastTime) excessCount++;
}
} else if (revisionData.hasOlder) {
excessCount++;
}
entry.batch_id = batchId;
yield entry;
}
if (excessCount >= excessLimit) break;
continueToken = data?.continue;
} while (continueToken);
}
async function* cachedAbuseLogGenerator(target, type, start, end) {
cachedAbuseLogGenerator.cache ||= new Map();
const cacheKey = `${target}-${type}-${start}-${end}`;
const cached = cachedAbuseLogGenerator.cache.get(cacheKey);
if (start && cached) {
clearTimeout(cached.timer);
cached.timer = setTimeout(() => cachedAbuseLogGenerator.cache.delete(cacheKey), 600000);
yield* cached.entries;
return;
}
const entries = await Array.fromAsync(abuseLogGenerator(target, type, start, end));
yield* entries;
const timer = setTimeout(() => cachedAbuseLogGenerator.cache.delete(cacheKey), 600000);
cachedAbuseLogGenerator.cache.set(cacheKey, { entries, timer });
}
async function* logGenerator(letype, target, type, start) {
const revisionCount = Object.keys(revisionData.revElements).length;
const limit = Math.min(50 + revisionCount, 250);
const excessLimit = revisionCount <= 50 ? 25 : 50;
let excessCount = 0;
// parsedcomment results are empty, query anyway in case it's fixed
const params = {
action: 'query',
list: 'logevents',
letype,
lelimit: limit,
leprop: 'ids|title|type|user|timestamp|parsedcomment|details',
format: 'json'
};
if (start) params.lestart = start;
if (type === 'user') params.leuser = target;
else if (type === 'page') params.letitle = target;
let continueToken = null;
do {
const batchId = Math.random();
const data = await api.get({ ...params, ...(continueToken || {}) });
if (data?.error) {
console.error(`Unfiltered: logGenerator error (${letype})`, data.error);
break;
}
for (const entry of data?.query?.logevents || []) {
if (revisionData.lastRevid) {
if (entry.revid) {
if (Number(entry.revid) < revisionData.lastRevid) excessCount++;
} else {
const lastTime = await revisionData.getTimestamp(revisionData.lastRevid);
if (entry.timestamp < lastTime) excessCount++;
}
} else if (revisionData.hasOlder) {
excessCount++;
}
entry.batch_id = batchId;
entry.result = 'disallow';
entry.filter_id = entry.logid;
entry.filter = strings[`log-name-${letype}`] || letype;
if (letype === 'spamblacklist') {
if (entry.params?.url) {
const urls = entry.params.url.split(/\s+/).filter(u => u);
try {
const hostnames = [...new Set(urls.map(u => new URL(u).hostname))];
if (hostnames.length === 1) {
entry.filter_id = hostnames[0];
}
} catch (error) {
console.warn('Unfiltered: invalid URL in log entry', error);
}
entry.filter += `: ${entry.params.url}`;
}
} else if (letype === 'abusefilterblockeddomainhit') {
if (entry.params?.blocked) {
entry.filter_id = entry.params.blocked;
entry.filter += `: ${entry.params.blocked}`;
}
}
yield entry;
}
if (excessCount >= excessLimit) break;
continueToken = data?.continue;
} while (continueToken);
}
async function* deletedRevisionsGenerator(target, type, start) {
const params = {
action: 'query',
list: 'alldeletedrevisions',
adrlimit: 50,
adrprop: 'flags|ids|parsedcomment|size|tags|timestamp|user',
format: 'json'
};
if (type === 'user') {
if (start) params.adrstart = start;
params.adruser = target;
} else {
params.adrfrom = params.adrto = mw.config.get('wgTitle');
params.adrnamespace = mw.config.get('wgNamespaceNumber');
}
let continueToken = null;
do {
const batchId = Math.random();
const data = await api.get({ ...params, ...(continueToken || {}) });
if (data?.error) {
console.error('Unfiltered: deletedRevisionsGenerator error', data.error);
break;
}
for (const page of data?.query?.alldeletedrevisions || []) {
for (const entry of page.revisions || []) {
const { tooNew, tooOld } = await checkBounds(entry, 'deleted');
if (tooOld) return;
if (tooNew) continue;
entry.title = page.title;
entry.batchId = batchId;
entry.revtype = 'deleted';
yield entry;
}
}
continueToken = data?.continue;
} while (continueToken);
}
async function* mergeGenerators(...generators) {
const promises = new Map();
for (let i = 0; i < generators.length; i++) {
promises.set(i, generators[i].next());
}
while (promises.size > 0) {
const entries = Array.from(promises.entries());
const result = await Promise.race(
entries.map(async ([id, promise]) => ({ id, value: await promise }))
);
if (result.value.done) {
promises.delete(result.id);
} else {
yield result.value.value;
const gen = generators[result.id];
promises.set(result.id, gen.next());
}
}
}
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 element = revisionData.revElements[revid] || revisionData.noRevElements[revid];
if (!element && (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';
element = createItem(first);
insertItem(element);
}
}
if (!element) continue;
let container = element.querySelector('.abusefilter-container');
if (!container) {
container = document.createElement('span');
container.className = 'abusefilter-container';
element.append(' ', container);
}
let diff = element.querySelector('.mw-changeslist-links .unfiltered-diff-unresolved');
for (const entry of hits[revid]) {
if (container.firstChild) {
container.prepend(' ');
}
container.prepend(createFilterElement(entry));
if (diff && entry.id && entry.filter_id !== 'private') {
resolveDiff(diff, entry.id);
diff = null;
}
}
matched.push(revid);
// only fire for user pages to keep IP Information gadget from duplicating icons
if (context.type === 'user') {
contentChanges.add(element);
}
}
for (const revid of matched) {
delete hits[revid];
}
}
function updateWarned(warned) {
for (const [revid, filterText] of warned) {
const element = revisionData.revElements[revid];
if (!element) return;
const filters = element.querySelectorAll('.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');
warned.delete(revid);
break;
}
}
}
}
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 = strings['red-link-title'].replace('$1', link.title);
}
} else {
link.classList.remove('new');
}
}
}
}
function insertItem(li) {
return mutex.run(() => insertItemUnsafe(li));
}
async function insertItemUnsafe(li) {
if (!firstInsert) {
firstInsert = true;
addToggleButton();
}
const allLis = Array.from(document.querySelectorAll('ul.mw-contributions-list > li:where([data-mw-revid],[data-revid],[data-norevid])'));
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');
if (entry.logid) {
link.href = `${articlePath}${specialName}:${strings.log}?logid=${entry.logid}`;
} else {
link.href = `${articlePath}${specialName}:${strings.abuselog}/${entry.id}`;
}
link.textContent = entry.filter_id;
element.append(link);
} else {
element.textContent = strings['abusefilter-hidden'];
}
return element;
}
function resolveDiff(diffSpan, logid) {
const link = document.createElement('a');
link.textContent = diffSpan.textContent;
link.href = `${articlePath}${specialName}:${strings.abuselog}/${logid}`;
link.className = 'unfiltered-diff';
link.dataset.unfilteredLogId = logid;
diffSpan.replaceChildren(link);
diffSpan.classList.remove('unfiltered-diff-unresolved');
}
function createItem(entry) {
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);
if (context.type === 'page') {
li.append(createDiffLinks(entry, pageTitleEncoded), ' ', createSeparator(), ' ');
}
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=${specialName}:${strings.undelete}&target=${pageTitleEncoded}×tamp=${ts}`;
timestamp.title = `${specialName}:${strings.undelete}`;
timestamp.textContent = formattedTimestamp;
} else {
timestamp = document.createElement('span');
timestamp.className = 'mw-changeslist-date';
timestamp.textContent = formattedTimestamp;
}
li.append(timestamp, ' ');
if (includeFields.has('title') && entry.title) {
li.append(createDiffLinks(entry, pageTitleEncoded), ' ');
}
let titleSpanWrapper;
if (includeFields.has('title') && entry.title) {
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);
addedTitles.push(titleLink);
}
const label = document.createElement('span');
label.textContent = strings['revision-' + entry.revtype] || entry.revtype;
label.style.fontStyle = 'italic';
li.append(createSeparator(), ' ', label, ' ', createSeparator(), ' ');
if (entry.revtype === 'deleted') {
if (entry.minor !== undefined) {
const minorAbbr = document.createElement('abbr');
minorAbbr.className = 'minoredit';
minorAbbr.title = strings['recentchanges-label-minor'];
minorAbbr.textContent = strings.minoreditletter;
li.append(' ', minorAbbr, ' ');
}
if (entry.parentid === 0) {
const newAbbr = document.createElement('abbr');
newAbbr.className = 'newpage';
newAbbr.title = strings.newpage;
newAbbr.textContent = strings.newpageletter;
li.append(' ', newAbbr, ' ');
}
}
if (titleSpanWrapper) {
li.append(titleSpanWrapper, ' ', createSeparator(), ' ');
}
if (includeFields.has('user') && entry.user) {
li.append(createUserNodes(entry));
}
if (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;
}
function createDiffLinks(entry, pageTitleEncoded) {
const linksSpan = document.createElement('span');
linksSpan.className = 'mw-changeslist-links';
const diffSpan = document.createElement('span');
diffSpan.className = 'unfiltered-diff-unresolved';
if (context.type === 'user') {
diffSpan.textContent = strings.diff;
const historySpan = document.createElement('span');
const historyLink = document.createElement('a');
if (entry.revtype === 'deleted') {
historyLink.href = `${scriptPath}?title=${specialName}:${strings.undelete}&target=${pageTitleEncoded}`;
} else {
historyLink.href = `${articlePath}${pageTitleEncoded}?action=history`;
}
historyLink.textContent = strings.hist;
historyLink.className = 'mw-changeslist-history';
historySpan.append(historyLink);
linksSpan.append(diffSpan, ' ', historySpan);
} else {
linksSpan.classList.add('mw-history-histlinks');
diffSpan.textContent = strings.last;
const curSpan = document.createElement('span');
curSpan.textContent = strings.cur;
linksSpan.append(curSpan, ' ', diffSpan);
}
return linksSpan;
}
function createSeparator() {
const span = document.createElement('span');
span.className = 'mw-changeslist-separator';
return span;
}
function createUserNodes(entry) {
let nodes;
if (context.type === 'user') {
nodes = document.createDocumentFragment();
} else {
nodes = document.createElement('span');
nodes.className = 'history-user';
}
const isAnon = mw.util.isIPAddress(entry.user, true);
const userEncoded = mw.util.wikiUrlencode(entry.user);
const userNamespace = mw.config.get('wgFormattedNamespaces')?.[2] || 'User';
const userLink = document.createElement('a');
userLink.className = isAnon ? 'mw-userlink mw-anonuserlink' : 'mw-userlink';
if (isAnon) {
userLink.href = `${articlePath}${specialName}:${strings.contributions}/${userEncoded}`;
} else {
userLink.href = `${articlePath}${userNamespace}:${userEncoded}`;
userLink.title = `${userNamespace}:${entry.user}`;
addedTitles.push(userLink);
}
if (entry.revid) userLink.setAttribute('data-mw-revid', entry.revid);
const bdi = document.createElement('bdi');
bdi.textContent = entry.user;
userLink.append(bdi);
if (context.type === 'user') {
const wrapper = document.createElement('bdi');
wrapper.setAttribute('dir', 'ltr');
wrapper.append(userLink);
nodes.append(wrapper);
} else {
nodes.append(userLink);
}
const toolsSpan = document.createElement('span');
toolsSpan.className = 'mw-usertoollinks mw-changeslist-links';
const talkSpan = document.createElement('span');
const talkLink = document.createElement('a');
const userTalkNamespace = mw.config.get('wgFormattedNamespaces')?.[3] || 'User talk';
const encodedTalk = mw.util.wikiUrlencode(`${userTalkNamespace}:${entry.user}`);
talkLink.href = `${articlePath}${encodedTalk}`;
talkLink.className = 'mw-usertoollinks-talk';
talkLink.title = `${userTalkNamespace}:${entry.user}`;
talkLink.textContent = 'talk';
addedTitles.push(talkLink);
talkSpan.append(talkLink);
toolsSpan.append(talkSpan);
if (!isAnon) {
const contribsSpan = document.createElement('span');
const contribsLink = document.createElement('a');
contribsLink.href = `${articlePath}${specialName}:${strings.contributions}/${userEncoded}`;
contribsLink.className = 'mw-usertoollinks-contribs';
contribsLink.title = `${specialName}:${strings.contributions}/${entry.user}`;
contribsLink.textContent = 'contribs';
contribsSpan.append(contribsLink);
toolsSpan.append(contribsSpan);
}
if (context.type === 'page' && rights.block) {
const blockSpan = document.createElement('span');
const blockLink = document.createElement('a');
blockLink.href = `${articlePath}${specialName}:Block/${userEncoded}`;
blockLink.className = 'mw-usertoollinks-block';
blockLink.title = `${specialName}:Block/${entry.user}`;
blockLink.textContent = 'block';
blockSpan.append(blockLink);
toolsSpan.append(blockSpan);
}
nodes.append(' ', toolsSpan);
return nodes;
}
function offsetIso(iso, seconds = 0) {
const date = new Date(iso);
date.setSeconds(date.getSeconds() + seconds);
if (seconds && date > new Date()) return null;
return date.toISOString();
}
function mwToIso(t) {
if (!t || t.length !== 14) return null;
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`;
}
});