'use strict';
mw.loader.using(['mediawiki.util']).then(function () {
const page = mw.config.get('wgPageName');
const filterMatch = page.match(/^Special:AbuseFilter\/(?:history\/)?(\d+)/);
if (!filterMatch) return;
const filterId = parseInt(filterMatch[1], 10);
const versionCache = {};
let checkedVersions;
let dialog;
let abortController;
mw.util.addPortletLink('p-cactions', '#', 'Blame', 't-filterblame')
.addEventListener('click', (e) => {
e.preventDefault();
openDialog();
});
function addStyles() {
mw.util.addCSS(`
dialog {
border: 2px solid var(--border-color-subtle, gray);
overflow: hidden;
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.7);
}
`);
}
function addDialog() {
dialog = document.createElement('dialog');
dialog.innerHTML = `
<p><big>Filter blame</big></p>
<fieldset>
<legend>Search</legend>
<input type="text" id="blame-search" name="search" style="width:100%" />
</fieldset>
<fieldset>
<legend>Match type</legend>
<label><input type="radio" name="matchType" value="string" checked> String</label>
<label><input type="radio" name="matchType" value="regex"> Regex</label>
<label><input type="checkbox" id="caseInsensitive" name="caseInsensitive"> Case-insensitive</label>
</fieldset>
<fieldset>
<legend>Search method</legend>
<label><input type="radio" name="searchMethod" value="binary" checked> Binary</label>
<label><input type="radio" name="searchMethod" value="linear"> Linear</label>
</fieldset>
<fieldset>
<legend>Change type</legend>
<label><input type="radio" name="changeType" value="addition" checked> Addition</label>
<label><input type="radio" name="changeType" value="removal"> Removal</label>
</fieldset>
<output id="blame-results"></output>
<div id="blame-buttons">
<button type="submit" id="blame-search-btn">Search</button>
<button type="button" id="blame-cancel-btn">Cancel</button>
</div>
`;
document.body.appendChild(dialog);
dialog.addEventListener('close', function () {
if (abortController) {
abortController.abort();
}
});
document.getElementById('blame-search-btn').addEventListener('click', function () {
if (abortController) {
abortController.abort();
}
abortController = new AbortController();
const searchParams = {
search: document.getElementById('blame-search').value,
matchType: document.querySelector('input[name="matchType"]:checked').value,
caseInsensitive: document.getElementById('caseInsensitive').checked,
invertMatch: document.querySelector('input[name="changeType"]:checked').value === 'removal',
};
const method = document.querySelector('input[name="searchMethod"]:checked').value;
updateResults(`<p>Fetching ${filterId} version list...</p>`);
fetchAllVersions().then((versions) => {
if (versions.length === 1) {
updateResults('<p>Only one version found. Cannot perform search.</p>');
return;
}
updateResults(`<p>Found ${versions.length} versions. Searching...</p>`);
checkedVersions = new Set();
if (method === 'linear') {
linearSearch(versions, searchParams);
} else {
binarySearch(versions, searchParams);
}
}).catch((err) => {
updateResults('<p>Search failed or was aborted.</p>');
console.error(`Search error: ${err.message || err}`);
});
});
document.getElementById('blame-cancel-btn').addEventListener('click', function () {
if (abortController) {
abortController.abort();
}
dialog.close();
});
}
function updateResults(content) {
document.getElementById('blame-results').innerHTML = content || '';
}
function openDialog() {
if (!dialog) {
addStyles();
addDialog();
}
updateResults();
dialog.showModal();
}
async function fetchAllVersions() {
const versions = [];
let url = `/w/index.php?title=Special:AbuseFilter/history/${filterId}`;
while (url) {
try {
const html = await fetch(url, { signal: abortController.signal }).then(res => res.text());
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
for (const el of doc.querySelectorAll('tr[class^="mw-abusefilter-history-id-"]')) {
const link = el.querySelector('td a[href*="/item/"]').getAttribute('href');
if (link) {
const revMatch = link.match(/\/item\/(\d+)/);
if (revMatch) {
const versionId = parseInt(revMatch[1], 10);
versions.push(versionId);
}
}
}
const nextLink = doc.querySelector('.TablePager-button-next a[href*="&offset="]');
url = nextLink?.getAttribute('href') ?? null;
} catch (e) {
updateResults('<p>Version fetching failed.</p>');
console.error(`Version fetch error for ${url}: ${e.message || e}`);
break;
}
}
return versions;
}
async function fetchFilterText(versionId) {
checkedVersions.add(versionId);
if (versionCache[versionId]) {
return versionCache[versionId];
}
const url = `https://demo.azizisearch.com/lite/wikipedia/page/Special:AbuseFilter/history/${filterId}/item/${versionId}`;
const html = await fetch(url).then(res => res.text());
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const text = doc.querySelector('#wpFilterRules')?.value || '';
versionCache[versionId] = text;
return text;
}
async function match(versionId, searchParams) {
const text = await fetchFilterText(versionId);
const maybeInvert = (result) => searchParams.invertMatch ? !result : result;
if (searchParams.matchType === 'string') {
if (searchParams.caseInsensitive) {
return maybeInvert(text.toLowerCase().includes(searchParams.search.toLowerCase()));
}
return maybeInvert(text.includes(searchParams.search));
}
try {
const flags = searchParams.caseInsensitive ? 'i' : '';
const re = new RegExp(searchParams.search, flags);
return maybeInvert(re.test(text));
} catch (e) {
updateResults(`<p>Invalid regex pattern: ${e.message}</p>`);
throw e;
}
}
function displayChangeLinks(version, versionCount) {
const base = `https://demo.azizisearch.com/lite/wikipedia/page/Special:AbuseFilter/history/${filterId}`;
updateResults(`
<p><b>Change found at
<a href="${base}/item/${version}" target="_blank">version ${version}</a>
(<a href="${base}/diff/prev/${version}" target="_blank">diff</a>)</b></p>
<p>Fetched ${checkedVersions.size} / ${versionCount} versions</p>
`);
}
async function linearSearch(versions, searchParams) {
for (let i = 0; i < versions.length - 1; i++) {
const newerId = versions[i];
const olderId = versions[i + 1];
const newerMatch = await match(newerId, searchParams);
const olderMatch = await match(olderId, searchParams);
const changed = newerMatch && !olderMatch;
if (changed) {
displayChangeLinks(newerId, versions.length);
return;
}
}
updateResults('<p>No version found that matches the criteria.</p>');
}
async function binarySearch(versions, searchParams) {
function interleavingSearchOrder(length) {
const result = [];
const grabbed = new Array(length).fill(false);
let step;
let divisor = 2;
do {
step = Math.max(Math.floor(length / divisor), 1);
for (let i = 0; i < length; i += step) {
if (!grabbed[i]) {
result.push(i);
grabbed[i] = true;
}
}
divisor *= 2;
} while (step > 1);
return result;
}
let left = 0; // newest version (descending order)
let right = versions.length - 1; // oldest version
const newestVal = await match(versions[left], searchParams);
const oldestVal = await match(versions[right], searchParams);
if (newestVal === false) {
if (searchParams.invertMatch === false) {
updateResults('<p>Current version missing text.</p>');
} else {
updateResults('<p>Current version contains text.</p>');
}
return;
}
if (newestVal === oldestVal) {
const interleavedIndices = interleavingSearchOrder(versions.length);
let found = false;
updateResults('<p>Same result in oldest and newest. Searching...</p>');
for (const i of interleavedIndices) {
if (i === 0) continue; // skip newest, already tested
const val = await match(versions[i], searchParams);
if (val !== newestVal) {
right = i;
found = true;
break;
}
}
if (!found) {
updateResults('<p>No differing result found.</p>');
return;
}
}
while (left < right) {
const mid = Math.floor((left + right) / 2);
const midVal = await match(versions[mid], searchParams);
if (midVal) {
// match: keep searching older
left = mid + 1;
} else {
// no match: go newer
right = mid;
}
}
const matchIndex = left;
const matchVersion = versions[matchIndex];
const nextVersion = versions[Math.max(matchIndex - 1, 0)];
const matchText = await match(matchVersion, searchParams);
const nextMatch = await match(nextVersion, searchParams);
const changed = !matchText && nextMatch;
if (changed) {
displayChangeLinks(nextVersion, versions.length);
} else {
updateResults('<p>No version found that matches the criteria.</p>');
}
}
});