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;

}

}

}

}

});