User:Daniel Quinlan/Scripts/Blame.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 code will be executed when previewing this page.
This user script seems to have a documentation page at User:Daniel Quinlan/Scripts/Blame.
'use strict';
mw.loader.using(['mediawiki.api', 'mediawiki.storage', 'mediawiki.user', 'mediawiki.util', 'mediawiki.DateFormatter', 'oojs-ui']).then(function () {
const namespace = mw.config.get('wgNamespaceNumber');
const action = mw.config.get('wgAction');
const special = mw.config.get('wgCanonicalSpecialPageName');
const page = mw.config.get('wgPageName');
let target;
if (namespace >= 0 && ['edit', 'history', 'view'].includes(action)) {
target = page;
} else if (special === 'AbuseFilter') {
const parts = page.split('/').slice(1);
if (parts[0] === 'history') parts.shift();
if (/^\d+$/.test(parts[0])) target = parts[0];
}
if (!target) return;
const api = new mw.Api();
const scriptPath = mw.config.get('wgScript');
const articlePath = mw.config.get('wgArticlePath');
const portletId = ['minerva', 'monobook'].includes(mw.config.get('skin')) ? 'p-tb' : 'p-cactions';
const dateFormat = mw.user.options.get('date')?.toLowerCase().replace(/\s/g, '') || 'none';
const userSpace = mw.config.get('wgFormattedNamespaces')[2];
let windowManager = null;
mw.util.addPortletLink(portletId, '#', 'Blame', 't-blame', 'Find changes in history')
.addEventListener('click', e => {
e.preventDefault();
openDialog();
});
function addStyles() {
mw.util.addCSS(`
.blame-dialog-frame { height: 90vh !important; }
.blame-flags { display: flex; width: 150px; }
.blame-flags .oo-ui-optionWidget { display: flex; flex: 1; }
.blame-flags .oo-ui-buttonElement-button { width: 100%; }
.blame-row:not(:last-child) { margin-bottom: 0.5em; }
.oo-ui-textInputWidget.oo-ui-widget-enabled.oo-ui-flaggedElement-success .oo-ui-inputWidget-input {
border-color: var(--border-color-success, #229679);
}
.blame-message { margin-bottom: 0.5em; }
.blame-message-error .blame-message-status { color: var(--color-error, #de5a49); }
.blame-message-success .blame-message-status { color: var(--color-success, #229679); }
.blame-message-warning .blame-message-status { color: var(--color-warning, #a97e2a); }
.blame-message-diff::before { content: '('; }
.blame-message-diff::after { content: ')'; }
.blame-results-table {
table-layout: fixed;
border-collapse: separate;
border-spacing: 0 5px;
}
.blame-results-revision { text-align: right; padding-right: 1em; }
.blame-results-timestamp, .blame-results-diff, .blame-results-user {
text-align: left;
padding-right: 1em;
}
.blame-results-timestamp-iso8601 { padding-right: 0.25em; }
.blame-results-diff::before { content: '('; }
.blame-results-diff::after { content: ')'; }
.blame-results-user {
max-width: 20ch;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.blame-results-status { text-align: left; }
.blame-results-status[data-result="true"]::before {
content: '✓ ';
color: var(--color-success, #229679);
}
.blame-results-status[data-result="false"]::before {
content: '✕ ';
color: var(--color-error, #de5a49);
}
`);
}
class BlameDialog extends OO.ui.ProcessDialog {
static static = {
name: 'blame-dialog',
title: special === 'AbuseFilter' ? 'Filter blame' : 'Page blame',
actions: [
{ action: 'search', label: 'Search', flags: ['primary', 'progressive'] },
{ action: 'cancel', label: 'Cancel', flags: ['safe', 'close'] }
]
};
initialize() {
super.initialize();
// helpers
const createTextInput = (placeholder, validate) =>
new OO.ui.TextInputWidget({ placeholder, validate });
const createButton = options =>
new OO.ui.ButtonOptionWidget({ data: options.label?.toLowerCase(), ...options });
const createSelectInput = (...items) =>
new OO.ui.ButtonSelectWidget({
items: items.map(createButton),
multiselect: !items.some(i => i.selected)
});
const createLayout = (item, label, help) =>
new OO.ui.FieldLayout(item, { align: 'top', label, help });
const createRow = (...items) =>
new OO.ui.HorizontalLayout({
items: items.map(i => createLayout(...i)),
classes: ['blame-row']
});
const validSearch = new RegExp(/./);
const validCount = new RegExp(/^\d*$/);
const validBound = new RegExp(/^\d*$|^\d{4}-\d\d-\d\d([\sT]\d\d:\d\d(:\d\d)?)?$/);
const updateFlags = item => {
const isRegex = item.getData() === 'regex';
const flags = this.options.flags;
flags.findItemFromData('i').setLabel(isRegex ? 'i' : 'Case insensitive');
if (isRegex) {
flags.addItems(this.regexItems);
this.previousFlags?.forEach(f => flags.selectItem(f));
this.previousFlags = null;
} else {
this.previousFlags = flags.findSelectedItems()?.filter(f => f.data !== 'i');
// workaround: removeItems deselects all when removing selected items
this.previousFlags?.forEach(f => flags.unselectItem(f));
flags.removeItems(this.regexItems);
}
};
// options
this.highlightedOptions = new Set();
this.options = {
searchText: createTextInput('Text or regular expression', validSearch),
matchType: createSelectInput(
{ label: 'String', title: 'Exact text match', selected: true },
{ label: 'Regex', title: 'Regular expression match' }
),
flags: createSelectInput(
{ data: 'i', label: 'Case insensitive', title: 'Match regardless of case' }
),
method: createSelectInput(
{ label: 'Binary', title: 'Faster binary search', selected: true },
{ label: 'Linear', title: 'Slower chronological search' },
{ label: 'Complete', title: 'Slowest exhaustive search' }
),
changeType: createSelectInput(
{ label: 'Addition', title: 'Find the revision where text was added', selected: true },
{ label: 'Removal', title: 'Find the revision where text was removed' }
),
maxCount: createTextInput('Count (optional)', validCount),
skipCount: createTextInput('Count (optional)', validCount),
start: createTextInput('Revision or date (optional)', validBound),
end: createTextInput('Revision or date (optional)', validBound),
};
this.regexItems = [
{ label: 'm', title: 'Allow ^ and $ to match on each line' },
{ label: 's', title: 'Allow . to match newlines' }
].map(createButton);
// classes
this.options.flags.$element.addClass('blame-flags');
// results
this.resultsOutput = new OO.ui.PanelLayout({ padded: false, expanded: false });
// fieldsets
const boundHelp = new OO.ui.HtmlSnippet('Enter a revision ID (number) or date in <code>YYYY-MM-DD</code> format. Time can be added as <code>YYYY-MM-DDTHH:MM:SS</code> or <code>YYYY-MM-DD HH:MM</code>. Dates and times are interpreted using your timezone preference to match displayed timestamps.');
const maxCountHelp = 'Limits the number of revisions searched. Any start or end filtering is applied first.';
const skipCountHelp = 'Number of most recent revisions to skip. This filter is applied last.';
const fieldsets = {
'Search': [createLayout(this.options.searchText)],
'Matching': [createRow([this.options.matchType], [this.options.flags])],
'Search method': [createLayout(this.options.method)],
'Change type': [createLayout(this.options.changeType)],
'Revisions': [
createRow(
[this.options.maxCount, 'Maximum revisions', maxCountHelp],
[this.options.skipCount, 'Skip revisions', skipCountHelp]
),
createRow(
[this.options.start, 'Start (newest)', boundHelp],
[this.options.end, 'End (oldest)', boundHelp]
)
],
'Results': [this.resultsOutput]
};
// populate panel
this.content = new OO.ui.PanelLayout({ padded: true, expanded: false });
for (const [label, fields] of Object.entries(fieldsets)) {
const fieldset = new OO.ui.FieldsetLayout({ label, align: 'top' });
fieldset.addItems(fields);
this.content.$element.append(fieldset.$element);
if (label === 'Results') {
this.resultsFieldset = fieldset;
this.resultsFieldset.toggle(false);
}
}
// defaults
const highlightOption = field => {
this.highlightedOptions.add(field);
field.setFlags({ success: true });
field.once('change', () => field.setFlags({ success: false }));
};
const selectedText = window.getSelection().toString();
if (selectedText) {
this.options.searchText.setValue(selectedText);
highlightOption(this.options.searchText);
}
const revisionId = mw.config.get('wgRevisionId');
if (revisionId && revisionId !== mw.config.get('wgCurRevisionId')) {
this.options.start.setValue(revisionId);
highlightOption(this.options.start);
}
// event handling
for (const item of Object.values(this.options)) {
if (item instanceof OO.ui.TextInputWidget) {
item.on('enter', () => { this.executeAction('search'); });
}
}
this.options.matchType.on('select', item => updateFlags(item));
this.options.method.on('select', item => {
const method = item?.getData();
if (method !== 'binary' && !this.options.maxCount.getValue().trim()) {
this.options.maxCount.setValue('500');
highlightOption(this.options.maxCount);
}
const isComplete = method === 'complete';
const changeType = this.options.changeType;
const selected = changeType.findSelectedItem?.()?.getData();
if (isComplete) {
if (!this.previousChangeType) this.previousChangeType = selected;
changeType.selectItem();
} else if (this.previousChangeType) {
changeType.selectItemByData(this.previousChangeType);
this.previousChangeType = null;
}
changeType.findItemFromData('addition').setDisabled(isComplete);
changeType.findItemFromData('removal').setDisabled(isComplete);
});
// finish
this.$body.append(this.content.$element);
}
getActionProcess(action) {
if (action === 'search') {
this.highlightedOptions.forEach(field => field.setFlags({ success: false }));
return new OO.ui.Process(() => this.startSearch());
}
if (action === 'cancel') {
return new OO.ui.Process(() => this.close({ action: 'cancel' }));
}
return super.getActionProcess(action);
}
async startSearch() {
this.resultsFieldset.toggle(true);
this.blame.messages = new MessageHandler(this.resultsOutput);
let searchParams;
try {
searchParams = this.getValidatedParams();
} catch (e) {
this.blame.messages.error(e.message);
return;
}
await this.blame.search(searchParams);
}
getValidatedParams() {
const getOption = item => {
if (item.getValue) return item.getValue();
const items = item.findSelectedItems();
return Array.isArray(items) ? items.map(i => i.getData()) : items?.getData();
};
const params = Object.fromEntries(
Object.entries(this.options).map(([key, item]) => [key, getOption(item)])
);
params.matcher = this.buildMatcher(params);
params.maxCount = this.parseNumber(params.maxCount, 'Maximum');
params.skipCount = this.parseNumber(params.skipCount, 'Skip');
params.start = this.parseBound(params.start, 'Start');
params.end = this.parseBound(params.end, 'End');
return params;
}
buildMatcher(searchParams) {
const { searchText, matchType, flags, changeType } = searchParams;
const flagsString = flags ? flags.join('') : '';
if (!searchText) {
throw new Error('Please enter a search term.');
}
let matcher;
if (matchType === 'string') {
if (flagsString.includes('i')) {
const lower = searchText.toLowerCase();
matcher = text => text.toLowerCase().includes(lower);
} else {
matcher = text => text.includes(searchText);
}
} else {
try {
const regex = new RegExp(searchText, flagsString + 'u');
matcher = text => regex.test(text);
} catch (e) {
throw new Error(`Invalid regex pattern: ${e.message}`);
}
}
if (changeType === 'removal') {
return text => !matcher(text);
}
return matcher;
}
parseNumber(input, field) {
const trimmed = input.trim();
if (!trimmed) return null;
if (/^\d+$/.test(trimmed)) return parseInt(trimmed, 10);
throw new Error(`${field} must be a valid number.`);
}
parseBound(input, field) {
try {
const trimmed = input.trim();
if (!trimmed) return null;
if (/^\d+$/.test(trimmed)) return parseInt(trimmed, 10);
const tz = this.getTimezone();
let dateString = trimmed;
if (!dateString.includes(':')) {
dateString += field === 'Start' ? 'T23:59:59' : 'T00:00:00';
}
const date = new Date(dateString + tz);
if (isNaN(date.getTime())) throw new Error('invalid date');
return date;
} catch (e) {
throw new Error(`${field} must be a valid revision ID or date: ${e.message}`);
}
}
getTimezone() {
const correction = mw.user.options.get('timecorrection') || '';
const match = correction.match(/^(?:Offset|System|ZoneInfo)\|(-?\d+)(?:\||$)/);
const minutes = match ? Number(match[1]) : -new Date().getTimezoneOffset();
const sign = minutes >= 0 ? '+' : '-';
const abs = Math.abs(minutes);
const pad = n => String(n).padStart(2, '0');
return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
}
}
function openDialog() {
if (!windowManager) {
addStyles();
windowManager = new OO.ui.WindowManager();
document.body.append(windowManager.$element[0]);
}
const dialog = new BlameDialog({ size: 'large', classes: ['blame-dialog'] });
dialog.blame = special === 'AbuseFilter'
? new AbuseFilterBlame(target)
: new PageBlame(target);
windowManager.addWindows([dialog]);
const windowPromise = windowManager.openWindow(dialog);
windowPromise.opened.then(() => {
dialog.options.searchText.focus();
});
windowPromise.closed.then(() => {
windowManager.removeWindows([BlameDialog.static.name]);
if (dialog.blame) dialog.blame.abortSearch = true;
});
dialog.$element.find('.oo-ui-window-frame').addClass('blame-dialog-frame');
}
class IndirectMap extends Map {
constructor() {
super();
this.index = new Map();
}
bind(key, id) {
this.index.set(key, id);
}
set(key, value) {
const id = this.index.get(key);
if (id) super.set(id, value);
}
get(key) {
const id = this.index.get(key);
return id ? super.get(id) : undefined;
}
has(key) {
const id = this.index.get(key);
return id ? super.has(id) : false;
}
}
class Blame {
constructor(target) {
this.target = target;
this.userDate = mw.loader.require('mediawiki.DateFormatter').formatTimeAndDate;
}
async match(versionId, searchParams, ordering = null) {
const text = await this.fetchContent(versionId, searchParams, ordering);
if (text === null) {
this.messages.error('Revision content unavailable: ', this.createLinks(versionId));
this.matchResults.set(versionId, false);
if (searchParams.method !== 'complete') {
this.abortSearch = true;
}
return false;
}
try {
const result = searchParams.matcher(text);
this.matchResults.set(versionId, result);
return result;
} catch (e) {
this.messages.error(`Matching error: ${e.message}`);
this.matchResults.set(versionId, false);
this.abortSearch = true;
return false;
}
}
async linearSearch(searchParams) {
for (let i = 0; i < this.versions.length - 1; i++) {
if (this.abortSearch) return;
const newerMatch = await this.match(this.versions[i], searchParams);
const olderMatch = await this.match(this.versions[i + 1], searchParams);
if (newerMatch && !olderMatch) {
this.messages.clear();
this.messages.success('Found change: ', this.createLinks(this.versions[i]));
return;
}
}
this.messages.clear();
this.messages.warning('Search completed, but no revision found where the search result changed.');
}
async completeSearch(searchParams) {
let matches = 0;
for (let i = 0; i < this.versions.length; i++) {
if (this.abortSearch) return;
if (await this.match(this.versions[i], searchParams)) matches++;
}
this.messages.clear();
const fragment = document.createDocumentFragment();
fragment.append(`${matches}/${this.versions.length} matched`);
this.messages.success('Search completed: ', fragment);
}
interleaveArray(array, max) {
const length = array.length;
if (length <= 1) return array.slice();
const result = [];
const visited = new Array(length).fill(false);
const addIndex = (i) => {
if (!visited[i]) {
visited[i] = true;
result.push(array[i]);
}
};
addIndex(0);
addIndex(length - 1);
let m = 1;
const limit = (max == null ? length : Math.min(max, length));
while (result.length < limit) {
const divisor = 1 << m;
for (let k = 1; k < divisor; k += 2) {
addIndex(Math.floor((k / divisor) * (length - 1)));
if (result.length >= limit) break;
}
m++;
}
return result;
}
async binarySearch(searchParams) {
let left = 0; // newest version (descending order)
let right = this.versions.length - 1; // oldest version
const newestVal = await this.match(this.versions[left], searchParams);
const oldestVal = await this.match(this.versions[right], searchParams);
if (newestVal === oldestVal) {
this.messages.notice('Newest and oldest revisions match. Probing intermediate revisions (may take a while) to locate a differing revision...');
const interleaved = this.interleaveArray(this.versions);
let found = false;
// start at 1 to skip newest, already tested
for (let i = 1; i < interleaved.length; i++) {
if (this.abortSearch) return;
const val = await this.match(interleaved[i], searchParams, interleaved);
if (val !== newestVal) {
right = this.versions.indexOf(interleaved[i]);
found = true;
break;
}
}
if (!found) {
this.messages.clear();
this.messages.warning('Search completed, but no revision found where the search result changed.');
return;
}
}
while (left < right) {
if (this.abortSearch) return;
const mid = Math.floor((left + right) / 2);
const midVal = await this.match(this.versions[mid], searchParams);
if (midVal) {
left = mid + 1; // match: keep searching older
} else {
right = mid; // no match: go newer
}
}
const matchIndex = left;
const matchVersion = this.versions[matchIndex];
const nextVersion = this.versions[Math.max(matchIndex - 1, 0)];
const matchText = await this.match(matchVersion, searchParams);
const nextMatch = await this.match(nextVersion, searchParams);
const changed = !matchText && nextMatch;
if (changed) {
this.messages.clear();
this.messages.success('Found change: ', this.createLinks(nextVersion));
} else {
this.messages.error('Unable to find the revision where the search result changed.');
}
}
async search(searchParams) {
const { method, changeType } = searchParams;
this.startTime = performance.now();
this.abortSearch = false;
this.matchResults = new Map();
this.messages.notice('Fetching revision list...');
await this.fetchVersions(searchParams);
if (method !== 'complete' && this.versions.length <= 1) {
this.messages.warning(`Only ${this.versions.length} revision${this.versions.length !== 1 ? 's' : ''} found. Cannot search.`);
return;
}
this.messages.notice(`Found ${this.versions.length} revision${this.versions.length !== 1 ? 's' : ''}. Searching...`);
if (method !== 'complete' && !await this.match(this.versions[0], searchParams)) {
this.messages.clear();
this.messages.warning(`Newest revision tested ${changeType === 'removal' ? 'contains' : 'missing'} text: `, this.createLinks(this.versions[0]));
return;
}
if (method === 'linear') await this.linearSearch(searchParams);
else if (method === 'complete') await this.completeSearch(searchParams);
else await this.binarySearch(searchParams);
this.addResults();
}
createLinks(versionId, table = false) {
const createLink = (href, textContent) =>
Object.assign(document.createElement('a'), { href, target: '_blank', textContent });
const links = this.resultLinks(versionId);
const timestamp = this.versionData.get(versionId)?.timestamp;
const ts = timestamp ? this.userDate(new Date(timestamp)) : undefined;
const user = this.versionData.get(versionId)?.user;
let userNode;
if (user) {
userNode = createLink(articlePath.replace('$1', `${userSpace}:${encodeURIComponent(user)}`), user);
} else {
userNode = document.createTextNode('unknown');
}
const diff = document.createElement('span');
diff.append(createLink(links.diff, 'diff'));
if (table) {
return {
revision: createLink(links.revision, versionId),
timestamp: createLink(links.revision, ts ?? 'unknown'),
diff,
user: userNode
};
}
const fragment = document.createDocumentFragment();
const text = ts ? `${versionId} \u00b7 ${ts}` : `revision ${versionId}`;
diff.className = 'blame-message-diff';
fragment.append(createLink(links.revision, text), ' ', diff, ' \u00b7 ', userNode);
return fragment;
}
addResults() {
if (this.matchResults.size) {
const elapsed = this.startTime ? ((performance.now() - this.startTime) / 1000).toFixed(2) : 'unknown';
this.messages.notice(`Searched ${this.matchResults.size}/${this.versions.length} revisions in ${elapsed} seconds.`);
}
const revids = Array.from(this.matchResults.keys()).sort((a, b) => b - a);
const table = document.createElement('table');
const tsClass = `blame-results-timestamp-${dateFormat}`;
table.className = 'blame-results-table';
revids.forEach(revid => {
const row = table.insertRow();
const cells = this.createLinks(revid, true);
cells.status = this.matchResults.get(revid).toString();
Object.entries(cells).forEach(([key, value]) => {
const cell = row.insertCell();
cell.append(value);
cell.className = `blame-results-${key}`;
if (key === 'timestamp') cell.classList.add(tsClass);
if (key === 'status') cell.setAttribute('data-result', value);
});
});
this.messages.notice(table);
}
}
class AbuseFilterBlame extends Blame {
static versionCache = new Map();
constructor(target) {
super(target);
this.aliasesPromise = AbuseFilterBlame.fetchAliases();
this.primaryAlias = null;
}
static async* logEvents(limit = 'max', letitle, start, end) {
const baseParams = {
action: 'query',
format: 'json',
list: 'logevents',
letype: 'abusefilter',
lelimit: limit
};
if (letitle) baseParams.letitle = letitle;
if (start instanceof Date) baseParams.lestart = start.toISOString();
if (end instanceof Date) baseParams.leend = end.toISOString();
let offset;
while (true) {
const params = { ...baseParams, ...(offset != null && { lecontinue: offset }) };
const result = await api.get(params);
const events = result.query?.logevents || [];
for (const ev of events) {
yield ev;
}
if (!result.continue?.lecontinue) break;
offset = result.continue.lecontinue;
}
}
static async fetchAliases() {
const siteId = mw.config.get('wgWikiID') || 'unknown';
if (['commonswiki', 'enwiki', 'metawiki', 'simplewiki', 'wikidatawiki'].includes(siteId)) {
return ['Special:AbuseFilter'];
}
const aliasesKey = `blame-aliases-${siteId}`;
const cached = mw.storage.getObject(aliasesKey);
if (cached) return cached;
const aliasSet = new Set();
for await (const ev of this.logEvents()) {
if (!ev.title) continue;
const alias = ev.title.split('/')[0];
if (alias) aliasSet.add(alias);
}
const aliasArray = Array.from(aliasSet);
mw.storage.setObject(aliasesKey, aliasArray, 604800);
return aliasArray;
}
async fetchVersions(searchParams) {
const { start, end, maxCount, skipCount } = searchParams;
const versions = [];
const versionData = new Map();
const limit = (maxCount && maxCount <= 500) ? maxCount : 'max';
const aliases = await this.aliasesPromise;
if (!this.primaryAlias) {
this.primaryAlias = aliases[0] || 'Special:AbuseFilter';
}
for (const alias of aliases) {
if (this.abortSearch) return;
const letitle = `${alias}/${this.target}`;
for await (const ev of AbuseFilterBlame.logEvents(limit, letitle, start, end)) {
const id = ev.params?.historyId ?? (ev.params?.[0] ? parseInt(ev.params[0], 10) : null);
if (id == null || !ev.timestamp) continue;
if (typeof start === 'number' && id > start) continue;
if (typeof end === 'number' && id < end) break;
versions.push(id);
versionData.set(id, { timestamp: ev.timestamp, user: ev.user });
}
}
if (aliases.length > 1)
versions.sort((a, b) => b - a);
if (maxCount && versions.length > maxCount)
versions.length = maxCount;
if (skipCount)
versions.splice(0, skipCount);
this.versions = versions;
this.versionData = versionData;
}
async fetchContent(versionId, searchParams, ordering = null) {
if (AbuseFilterBlame.versionCache.has(versionId)) {
return AbuseFilterBlame.versionCache.get(versionId);
}
const link = articlePath.replace('$1', `${this.primaryAlias}/history/${this.target}/item/${versionId}`);
try {
const html = await fetch(link, { cache: 'force-cache' }).then(r => {
if (!r.ok) throw new Error(`HTTP error: ${r.status} ${r.statusText}`);
return r.text();
});
const tree = new DOMParser().parseFromString(html, 'text/html');
const content = tree.querySelector('#wpFilterRules')?.value ?? null;
if (content !== null) {
AbuseFilterBlame.versionCache.set(versionId, content);
}
return content;
} catch (e) {
console.warn(`Failed to fetch revision ${versionId}: ${e.message}`);
return null;
}
}
resultLinks(versionId) {
return {
revision: articlePath.replace('$1', `${this.primaryAlias}/history/${this.target}/item/${versionId}`),
diff: articlePath.replace('$1', `${this.primaryAlias}/history/${this.target}/diff/prev/${versionId}`)
};
}
}
class PageBlame extends Blame {
static versionCache = new IndirectMap();
static MAX_BATCH_SIZE = 1024 * 1024;
async fetchVersions(searchParams) {
const { start, end, maxCount, skipCount } = searchParams;
const versions = [];
const versionData = new Map();
const limit = (maxCount && maxCount <= 500) ? maxCount : 'max';
let offset;
const baseParams = {
action: 'query',
format: 'json',
prop: 'revisions',
titles: this.target,
rvlimit: limit,
rvprop: 'ids|sha1|size|timestamp|user'
};
if (start instanceof Date) {
baseParams.rvstart = start.toISOString();
} else if (typeof start === 'number') {
baseParams.rvstartid = start;
}
if (end instanceof Date) {
baseParams.rvend = end.toISOString();
} else if (typeof end === 'number') {
baseParams.rvendid = end;
}
outer: while (true) {
if (this.abortSearch) return;
const params = { ...baseParams, ...(offset && { rvcontinue: offset }) };
const result = await api.get(params);
const pageObj = Object.values(result.query?.pages || {})[0];
if (!pageObj?.revisions) break;
for (const rev of pageObj.revisions) {
if (rev.sha1hidden || rev.suppressed) continue;
if (rev.revid == null || !rev.sha1 || rev.size == null || !rev.timestamp) continue;
versions.push(rev.revid);
PageBlame.versionCache.bind(rev.revid, rev.sha1);
versionData.set(rev.revid, {
size: rev.size,
timestamp: rev.timestamp,
user: rev.user
});
if (maxCount && versions.length >= maxCount) break outer;
}
if (!result.continue?.rvcontinue) break;
offset = result.continue.rvcontinue;
}
if (skipCount) {
versions.splice(0, skipCount);
}
this.versions = versions;
this.versionData = versionData;
}
batchRevisions(versions) {
let totalSize = 1024;
const revids = [];
for (const version of versions) {
const data = this.versionData.get(version);
if (!data || PageBlame.versionCache.has(version)) continue;
const size = Math.ceil(data.size * 1.1) + 1024;
if (totalSize + size > PageBlame.MAX_BATCH_SIZE) break;
totalSize += size;
revids.push(version);
if (revids.length >= 50) break;
}
return revids;
}
async fetchContent(versionId, searchParams, ordering = null) {
if (PageBlame.versionCache.has(versionId)) {
return PageBlame.versionCache.get(versionId);
}
const attempts = [String(versionId)];
if (searchParams.method !== 'binary' || ordering) {
const sequence = ordering ? ordering : this.versions;
const index = sequence.indexOf(versionId);
if (index >= 0) {
const bulk = this.batchRevisions(sequence.slice(index));
if (bulk.length) attempts.unshift(bulk.join('|'));
}
} else if (searchParams.method === 'binary' && versionId === this.versions[0]) {
const interleaved = this.interleaveArray(this.versions, 17); // 0/16 to 16/16
const prefetch = this.batchRevisions(interleaved);
if (prefetch.length) attempts.unshift(prefetch.join('|'));
}
const params = {
action: 'query',
format: 'json',
prop: 'revisions',
rvprop: 'ids|content',
rvslots: 'main'
};
let result;
for (const ids of attempts) {
try {
result = await api.get({ ...params, revids: ids });
break;
} catch (e) {
console.warn(`Failed to fetch revisions ${ids}: ${e.message}`);
}
}
if (!result) return null;
const pageObj = Object.values(result.query.pages)[0];
if (!pageObj?.revisions) return null;
for (const rev of pageObj?.revisions) {
let content = rev.slots?.main?.['*'] ?? null;
if (content !== null) {
PageBlame.versionCache.set(rev.revid, content);
}
}
return PageBlame.versionCache.get(versionId) ?? null;
}
resultLinks(versionId) {
const encodedTarget = encodeURIComponent(this.target);
return {
revision: `${scriptPath}?title=${encodedTarget}&oldid=${versionId}`,
diff: `${scriptPath}?title=${encodedTarget}&diff=prev&oldid=${versionId}`
};
}
}
class MessageHandler {
constructor(container) {
container.$element.empty();
this.container = container;
}
clear() {
if (!this.container.$element?.[0]?.querySelector('.blame-message-error')) {
this.container.$element.empty();
}
}
add(level, ...items) {
this.container.toggle(true);
const message = document.createElement('div');
message.className = `blame-message blame-message-${level}`;
items.forEach(i => {
if (i instanceof Node) {
message.append(i);
}
else {
const span = document.createElement('span');
span.className = 'blame-message-status';
span.textContent = i;
message.append(span);
}
});
this.container.$element.append(message);
const first = this.container.$element.find('div').first()[0];
if (first) first.scrollIntoView({ behavior: 'smooth', block: 'center' });
mw.hook('wikipage.content').fire(this.container.$element);
}
error(...args) { this.add('error', ...args); }
notice(...args) { this.add('notice', ...args); }
success(...args) { this.add('success', ...args); }
warning(...args) { this.add('warning', ...args); }
}
});