User:Daniel Quinlan/Scripts/FilterDiff.js
'use strict';
mw.loader.using(['mediawiki.util']).then(function () {
if (mw.config.get('wgCanonicalSpecialPageName') != "AbuseFilter")
return;
const page = mw.config.get('wgPageName');
const diffMatch = page.match(/\/history\/\d+\/diff\/\w+\/\w+/);
if (!diffMatch)
return;
const context = 3;
let displayFullContext = false;
mw.loader.addStyleTag(`
table.wikitable .diff-toggle-button-wrapper {
position: relative;
}
table.wikitable .diff-toggle-button {
position: absolute;
right: 0em;
top: 50%;
transform: translateY(-50%);
background: transparent;
border: none;
color: var(--color-subtle, gray);
display: flex;
padding: 0px;
cursor: pointer;
}
table.wikitable .diff-toggle-button:hover,
table.wikitable .diff-toggle-button:focus-visible {
color: var(--color-base--hover, gray);
}
table.wikitable tr:not(.mw-abusefilter-diff-header) > th:first-child {
display: none;
}
.diff col.diff-line-number { width: 3.5%; }
.diff col.diff-marker { width: 1.5%; }
.diff col.diff-content { width: 45%; }
.diff td.diff-line-number { position: relative; }
.diff td.diff-line-number::after {
content: attr(data-line-number);
position: absolute;
right: 0.3em;
top: 50%;
transform: translateY(-50%);
font-size: smaller;
font-family: monospace;
color: gray;
}
.diff td.diff-marker { font-size: 1em; }
.diff:not(.diff-full-context) .context-separator-above td {
border-bottom: 3px dotted var(--border-color-disabled, gray);
}
.diff:not(.diff-full-context) .context-separator-below td {
border-top: 3px dotted var(--border-color-disabled, gray);
}
.diff:not(.diff-full-context) tr.context-distant { display: none; }
`);
function processDiffTable(diffTable, lineNumbering) {
const rows = diffTable.querySelectorAll('tr');
const types = [];
// add columns
const colgroup = diffTable.querySelector('colgroup');
if (colgroup) {
const markerCol = colgroup.querySelector('.diff-marker');
const contentCol = colgroup.querySelector('.diff-content');
if (markerCol) {
const newCol = document.createElement('col');
newCol.className = 'diff-line-number';
colgroup.insertBefore(newCol, markerCol);
}
if (contentCol) {
const newCol = document.createElement('col');
newCol.className = 'diff-line-number';
contentCol.after(newCol);
}
}
// line numbering
let lineNumberDeleted = 0;
let lineNumberAdded = 0;
for (const row of rows) {
// add first line number as first column
const tdDel = document.createElement('td');
tdDel.className = 'diff-line-number';
row.insertBefore(tdDel, row.firstChild);
// add second line number as fourth column
const tdAdd = document.createElement('td');
let colCount = 0;
for (const td of row.querySelectorAll('td')) {
const colspan = parseInt(td.getAttribute('colspan') || '1', 10);
colCount += colspan;
if (colCount >= 3) {
tdAdd.className = 'diff-line-number';
td.after(tdAdd);
break;
}
}
// add line number attributes
if (lineNumbering) {
const deleted = row.querySelector('.diff-side-deleted');
const added = row.querySelector('.diff-side-added');
if (deleted && deleted.children.length > 0)
tdDel.setAttribute('data-line-number', ++lineNumberDeleted);
if (added && added.children.length > 0)
tdAdd.setAttribute('data-line-number', ++lineNumberAdded);
}
// classify row
let type = 'other';
const sides = row.querySelectorAll('td.diff-side-deleted, td.diff-side-added');
if (sides.length === 2) {
const oldCell = sides[0];
const newCell = sides[1];
const oldExists = oldCell.children.length > 0;
const newExists = newCell.children.length > 0;
if (oldExists && newExists && oldCell.textContent === newCell.textContent)
type = 'identical';
else
type = 'changed';
}
types.push(type);
}
// compute distances
const n = rows.length;
const distances = Array(n).fill(Infinity);
let lastChanged = -Infinity;
// forward pass
for (let i = 0; i < n; i++) {
if (types[i] === 'changed')
lastChanged = i;
else if (types[i] === 'identical')
distances[i] = i - lastChanged;
}
// backward pass
lastChanged = Infinity;
for (let i = n - 1; i >= 0; i--) {
if (types[i] === 'changed')
lastChanged = i;
else if (types[i] === 'identical')
distances[i] = Math.min(distances[i], lastChanged - i);
}
// apply classes
for (let i = 0; i < n; i++) {
const row = rows[i];
const distance = distances[i];
if (types[i] === 'identical') {
// add border classes as separators
if (distance === context) {
const prev = i > 0 ? distances[i - 1] : null;
const next = i + 1 < n ? distances[i + 1] : null;
if (prev === context + 1) row.classList.add('context-separator-below');
if (next === context + 1) row.classList.add('context-separator-above');
}
// hide distant rows
if (distance > context)
row.classList.add('context-distant');
}
}
}
function addHeaderToggle(header, diffTable) {
const expandIcon = '';
const collapseIcon = '';
const wrapper = document.createElement('div');
wrapper.className = 'diff-toggle-button-wrapper';
while (header.firstChild)
wrapper.appendChild(header.firstChild);
header.appendChild(wrapper);
const button = document.createElement('button');
button.className = 'diff-toggle-button';
button.innerHTML = expandIcon;
button.title = 'Expand';
button.addEventListener('click', (e) => {
e.stopPropagation();
const expanded = diffTable.classList.toggle('diff-full-context');
button.innerHTML = expanded ? collapseIcon : expandIcon;
button.title = expanded ? 'Collapse' : 'Expand';
});
wrapper.appendChild(button);
}
for (const wikiTable of document.querySelectorAll('table.wikitable')) {
const rows = wikiTable.querySelectorAll('tr');
let header = null;
for (const row of rows) {
if (row.classList.contains('mw-abusefilter-diff-header')) {
header = row.querySelector('th');
}
const diff = row.querySelector('table.diff');
if (diff) {
const labelText = row.querySelector('th:first-child')?.textContent || '';
const lineNumbering = !/^(?:actions|description|flags)\b/i.test(labelText);
processDiffTable(diff, lineNumbering);
if (header && diff.querySelector('tr.context-distant')) {
addHeaderToggle(header, diff);
header = null;
}
}
}
}
});