ENSIKLOPEDIA
Kembali ke Ensiklopedia
Arsip Wikipedia Indonesia
User:Daniel Quinlan/Scripts/Blame.js
'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 = mw.config.get('skin') === 'minerva' ? '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]);
windowManager.openWindow(dialog).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); }
}
});