MediaWiki:Common.js: Difference between revisions

From Tuflow
Jump to navigation Jump to search
Content deleted Content added
No edit summary
No edit summary
(8 intermediate revisions by the same user not shown)
Line 1: Line 1:
/* Any JavaScript here will be loaded for all users on every page load. */
/* Any JavaScript here will be loaded for all users on every page load. */


/* -------------------------------------------------------------------------
/* Add live search to hardware benchmark tables */
* Shared helpers
* ------------------------------------------------------------------------- */

function tuflowNormaliseSearchText(value) {
return String(value || '')
.toLocaleLowerCase()
.replace(/\s+/g, ' ')
.trim();
}


/*
* Parse a query into OR groups containing AND terms.
*
* Examples:
* raster terrain
* raster AND terrain
* QuickOSM OR HCMGIS
* "repository install" AND raster
*/
function tuflowParseSearchQuery(rawQuery) {
var query = String(rawQuery || '').trim();

if (!query) {
return [];
}

return query.split(/\s+OR\s+/i).map(function (group) {
var terms = [];
var pattern = /"([^"]+)"|(\S+)/g;
var match;

while ((match = pattern.exec(group)) !== null) {
var term = match[1] || match[2];

if (!/^AND$/i.test(term)) {
term = tuflowNormaliseSearchText(term);

if (term) {
terms.push(term);
}
}
}

return terms;
}).filter(function (group) {
return group.length > 0;
});
}

function tuflowMatchesSearch(text, rawQuery) {
var normalisedText = tuflowNormaliseSearchText(text);
var groups = tuflowParseSearchQuery(rawQuery);

if (!groups.length) {
return true;
}

return groups.some(function (terms) {
return terms.every(function (term) {
return normalisedText.includes(term);
});
});
}

function tuflowGetSearchTerms(rawQuery) {
var terms = [];

tuflowParseSearchQuery(rawQuery).forEach(function (group) {
group.forEach(function (term) {
if (terms.indexOf(term) === -1) {
terms.push(term);
}
});
});

return terms.sort(function (a, b) {
return b.length - a.length;
});
}

function tuflowEscapeRegExp(value) {
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function tuflowClearHighlights(root) {
if (!root) {
return;
}

Array.from(root.querySelectorAll('mark.search-highlight')).forEach(function (mark) {
var textNode = document.createTextNode(mark.textContent);
var parent = mark.parentNode;

if (!parent) {
return;
}

parent.replaceChild(textNode, mark);
parent.normalize();
});
}

function tuflowHighlightMatches(root, rawQuery) {
if (!root || !rawQuery) {
return;
}

tuflowClearHighlights(root);

var terms = tuflowGetSearchTerms(rawQuery);

if (!terms.length) {
return;
}

/*
* Longer terms are checked first so that phrases such as
* "repository install" are highlighted before "repository".
*/
terms.sort(function (a, b) {
return b.length - a.length;
});

var escapedTerms = terms.map(function (term) {
return tuflowEscapeRegExp(term);
});

var testPattern = new RegExp(
escapedTerms.join('|'),
'i'
);

var replacePattern = new RegExp(
escapedTerms.join('|'),
'ig'
);

var walker = document.createTreeWalker(
root,
NodeFilter.SHOW_TEXT,
{
acceptNode: function (node) {
if (!node.nodeValue || !node.nodeValue.trim()) {
return NodeFilter.FILTER_REJECT;
}

if (
node.parentNode &&
[
'SCRIPT',
'STYLE',
'MARK',
'TEXTAREA',
'INPUT',
'BUTTON'
].indexOf(node.parentNode.nodeName) !== -1
) {
return NodeFilter.FILTER_REJECT;
}

return testPattern.test(node.nodeValue)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
}
}
);

var textNodes = [];
var currentNode;

while ((currentNode = walker.nextNode())) {
textNodes.push(currentNode);
}

textNodes.forEach(function (node) {
var text = node.nodeValue;
var fragment = document.createDocumentFragment();
var lastIndex = 0;

text.replace(
replacePattern,
function (match, offset) {
if (offset > lastIndex) {
fragment.appendChild(
document.createTextNode(
text.slice(lastIndex, offset)
)
);
}

var normalisedMatch =
tuflowNormaliseSearchText(match);

var termIndex = terms.findIndex(function (term) {
return term === normalisedMatch;
});

/*
* Cycle through five highlight colours.
*/
var colourNumber =
(Math.max(termIndex, 0) % 5) + 1;

var mark = document.createElement('mark');

mark.className =
'search-highlight search-highlight-' +
colourNumber;

mark.textContent = match;
fragment.appendChild(mark);

lastIndex = offset + match.length;

return match;
}
);

if (lastIndex < text.length) {
fragment.appendChild(
document.createTextNode(
text.slice(lastIndex)
)
);
}

if (node.parentNode) {
node.parentNode.replaceChild(fragment, node);
}
});
}

/* -------------------------------------------------------------------------
* Hardware benchmark table search
* ------------------------------------------------------------------------- */

mw.hook('wikipage.content').add(function ($content) {
mw.hook('wikipage.content').add(function ($content) {
'use strict';
'use strict';
Line 21: Line 258:
table.dataset.searchInitialised = 'true';
table.dataset.searchInitialised = 'true';


/* Create a wrapper that follows the table's natural width */
var tableBlock = document.createElement('div');
var tableBlock = document.createElement('div');
tableBlock.className = 'benchmark-table-block';
tableBlock.className = 'benchmark-table-block';
Line 39: Line 275:
input.placeholder =
input.placeholder =
'Search processor, system name, RAM, or benchmark result';
'Search processor, system name, RAM, or benchmark result';
input.setAttribute(
input.setAttribute('aria-label', 'Search benchmark results');
'aria-label',
'Search benchmark results'
);


var clearButton = document.createElement('button');
var clearButton = document.createElement('button');
Line 61: Line 294:
tableBlock.insertBefore(controls, table);
tableBlock.insertBefore(controls, table);


var rows = Array.from(
var rows = Array.from(table.querySelectorAll('tr')).filter(function (row) {
table.querySelectorAll('tr')
).filter(function (row) {
return row.querySelectorAll('td').length > 0;
return row.querySelectorAll('td').length > 0;
});
});

function normaliseText(value) {
return value
.toLocaleLowerCase()
.replace(/\s+/g, ' ')
.trim();
}


function filterRows() {
function filterRows() {
var query = normaliseText(input.value);
var rawQuery = input.value.trim();
var visibleCount = 0;
var visibleCount = 0;


rows.forEach(function (row) {
rows.forEach(function (row) {
var matches =
tuflowClearHighlights(row);

query === '' ||
normaliseText(row.textContent).includes(query);
var matches = tuflowMatchesSearch(
row.textContent,
rawQuery
);


row.hidden = !matches;
row.hidden = !matches;
Line 87: Line 314:
if (matches) {
if (matches) {
visibleCount += 1;
visibleCount += 1;

if (rawQuery !== '') {
tuflowHighlightMatches(row, rawQuery);
}
}
}
});
});


resultCount.textContent = query
resultCount.textContent = rawQuery
? visibleCount +
? visibleCount + ' matching result' + (visibleCount === 1 ? '' : 's')
' matching result' +
(visibleCount === 1 ? '' : 's')
: rows.length + ' benchmark results';
: rows.length + ' benchmark results';


clearButton.hidden = query === '';
clearButton.hidden = rawQuery === '';
}
}


Line 111: Line 340:
});
});



/* Add live search to Example Model Catalogue tables */
/* -------------------------------------------------------------------------
* Category-aware Example Model Catalogue search
* ------------------------------------------------------------------------- */

mw.hook('wikipage.content').add(function ($content) {
mw.hook('wikipage.content').add(function ($content) {
'use strict';
'use strict';
Line 117: Line 350:
var action = mw.config.get('wgAction');
var action = mw.config.get('wgAction');


/* Do not run inside edit or preview screens */
/* Allow preview testing, but not the initial edit screen */
if (action === 'edit' || action === 'submit') {
if (action === 'edit') {
return;
return;
}
}
Line 132: Line 365:


/*
/*
* Reuse the hardware-search classes for the same appearance,
* Keep the search controls outside the responsive table wrapper.
* while retaining separate Example Model filtering behaviour.
* This prevents the search field from being clipped on desktop.
*/
*/
var tableWrapper = table.closest('.example-model-table-wrapper');

var tableBlock = document.createElement('div');
var tableBlock = document.createElement('div');
tableBlock.className = 'benchmark-table-block';
tableBlock.className =
'benchmark-table-block example-model-table-block';

tableBlock.style.display = 'block';
tableBlock.style.width = '100%';
tableBlock.style.maxWidth = '100%';
tableBlock.style.marginLeft = '0';
tableBlock.style.marginRight = '0';

if (tableWrapper && tableWrapper.parentNode) {
tableWrapper.parentNode.insertBefore(
tableBlock,
tableWrapper
);

tableBlock.appendChild(tableWrapper);
} else {
table.parentNode.insertBefore(
tableBlock,
table
);

tableBlock.appendChild(table);
}

table.style.boxSizing = 'border-box';
table.style.width = '100%';
table.style.minWidth = '100%';

var controls = document.createElement('div');
controls.className =
'benchmark-table-controls example-model-table-controls';
controls.style.boxSizing = 'border-box';

var searchWrapper = document.createElement('div');
searchWrapper.className = 'benchmark-table-search';

var input = document.createElement('input');
input.type = 'search';
input.className = 'benchmark-search-input';
input.placeholder =
'Search model category, description, or model name';
input.setAttribute(
'aria-label',
'Search Example Model Catalogue'
);

var clearButton = document.createElement('button');
clearButton.type = 'button';
clearButton.className = 'benchmark-search-clear';
clearButton.textContent = 'Clear';

var resultCount = document.createElement('div');
resultCount.className = 'benchmark-result-count';
resultCount.setAttribute('aria-live', 'polite');

searchWrapper.appendChild(input);
searchWrapper.appendChild(clearButton);

controls.appendChild(searchWrapper);
controls.appendChild(resultCount);

if (tableWrapper) {
tableBlock.insertBefore(
controls,
tableWrapper
);
} else {
tableBlock.insertBefore(
controls,
table
);
}

/*
* Catalogue logical columns:
* 0: Major category
* 1: Subcategory
* 2: Description
* 3: Model name
*/
var originalRows = Array.from(
table.querySelectorAll('tr')
).filter(function (row) {
return row.querySelectorAll('td').length > 0;
});

if (!originalRows.length) {
controls.remove();
return;
}

var rowParent = originalRows[0].parentNode;
var activeRowspans = [];
var records = [];

function getCellText(cell) {
return cell
? tuflowNormaliseSearchText(cell.textContent)
: '';
}

originalRows.forEach(function (row, rowIndex) {
var logicalCells = [];
var physicalCells = Array.from(row.cells);
var columnIndex = 0;

activeRowspans.forEach(function (active, index) {
if (active && active.endRow >= rowIndex) {
logicalCells[index] = active.cell;
}
});

physicalCells.forEach(function (cell) {
while (logicalCells[columnIndex]) {
columnIndex += 1;
}

var colspan = Math.max(cell.colSpan || 1, 1);
var rowspan = Math.max(cell.rowSpan || 1, 1);

for (
var offset = 0;
offset < colspan;
offset += 1
) {
var logicalColumn = columnIndex + offset;

logicalCells[logicalColumn] = cell;

if (rowspan > 1) {
activeRowspans[logicalColumn] = {
cell: cell,
endRow: rowIndex + rowspan - 1
};
}
}

columnIndex += colspan;
});

var categoryCell = logicalCells[0] || null;
var subcategoryCell = logicalCells[1] || null;
var descriptionCell = logicalCells[2] || null;
var modelNameCell = logicalCells[3] || null;

var categorySpansBoth =
categoryCell &&
subcategoryCell &&
categoryCell === subcategoryCell;

var category = getCellText(categoryCell);
var subcategory = categorySpansBoth
? ''
: getCellText(subcategoryCell);
var description = getCellText(descriptionCell);
var modelName = getCellText(modelNameCell);

records.push({
originalRow: row,
categoryCell: categoryCell,
subcategoryCell: categorySpansBoth
? null
: subcategoryCell,
descriptionCell: descriptionCell,
modelNameCell: modelNameCell,
category: category,
subcategory: subcategory,
categoryKey:
category || '__uncategorised__',
subcategoryKey:
subcategory || '__no_subcategory__',
searchableText: tuflowNormaliseSearchText(
[
category,
subcategory,
description,
modelName
].join(' ')
)
});
});

function cloneCell(cell) {
var clone;

if (!cell) {
return document.createElement('td');
}

clone = cell.cloneNode(true);
clone.removeAttribute('rowspan');
clone.removeAttribute('colspan');

return clone;
}

function removeDisplayedDataRows() {
Array.from(
table.querySelectorAll('tr')
).forEach(function (row) {
if (row.querySelectorAll('td').length > 0) {
row.remove();
}
});
}

function restoreOriginalRows() {
removeDisplayedDataRows();

records.forEach(function (record) {
tuflowClearHighlights(record.originalRow);
rowParent.appendChild(record.originalRow);
});
}

function getRunLength(
items,
startIndex,
keyFunction
) {
var startingKey =
keyFunction(items[startIndex]);
var count = 1;
var index;

for (
index = startIndex + 1;
index < items.length;
index += 1
) {
if (
keyFunction(items[index]) !== startingKey
) {
break;
}

count += 1;
}

return count;
}

function renderFilteredRows(matches, query) {
removeDisplayedDataRows();

matches.forEach(function (record, index) {
var previous =
index > 0 ? matches[index - 1] : null;

var categoryStarts =
!previous ||
previous.categoryKey !==
record.categoryKey;

var subcategoryStarts =
categoryStarts ||
previous.subcategoryKey !==
record.subcategoryKey;

var row = document.createElement('tr');

if (categoryStarts) {
var categoryCell =
cloneCell(record.categoryCell);

var categoryRowspan = getRunLength(
matches,
index,
function (item) {
return item.categoryKey;
}
);

categoryCell.rowSpan = categoryRowspan;

if (!record.subcategory) {
categoryCell.colSpan = 2;
}

row.appendChild(categoryCell);
}

if (
record.subcategory &&
subcategoryStarts
) {
var subcategoryCell =
cloneCell(record.subcategoryCell);

var subcategoryRowspan = getRunLength(
matches,
index,
function (item) {
return (
item.categoryKey +
'::' +
item.subcategoryKey
);
}
);

subcategoryCell.rowSpan =
subcategoryRowspan;

row.appendChild(subcategoryCell);
}

row.appendChild(
cloneCell(record.descriptionCell)
);

row.appendChild(
cloneCell(record.modelNameCell)
);

if (query) {
tuflowHighlightMatches(row, query);
}

rowParent.appendChild(row);
});
}

function filterRows() {
var rawQuery = input.value.trim();
var matches;

if (!rawQuery) {
restoreOriginalRows();

resultCount.textContent =
records.length + ' example models';

clearButton.hidden = true;
return;
}

matches = records.filter(function (record) {
return tuflowMatchesSearch(
record.searchableText,
rawQuery
);
});

renderFilteredRows(matches, rawQuery);

resultCount.textContent =
matches.length +
' matching model' +
(matches.length === 1 ? '' : 's');

clearButton.hidden = false;
}

input.addEventListener('input', filterRows);

clearButton.addEventListener(
'click',
function () {
input.value = '';
filterRows();
input.focus();
}
);

filterRows();
});
});

/* -------------------------------------------------------------------------
* QGIS Useful Plugins table search
* ------------------------------------------------------------------------- */

mw.hook('wikipage.content').add(function ($content) {
'use strict';

var action = mw.config.get('wgAction');

/* Allow preview testing, but not the initial edit screen */
if (action === 'edit') {
return;
}

$content.find('.qgis-plugin-results-table').each(function () {
var table = this;

if (table.dataset.pluginSearchInitialised === 'true') {
return;
}

table.dataset.pluginSearchInitialised = 'true';

var tableBlock = document.createElement('div');
tableBlock.className =
'benchmark-table-block qgis-plugin-table-block';

tableBlock.style.display = 'block';
tableBlock.style.width = '100%';
tableBlock.style.maxWidth = '100%';
tableBlock.style.marginLeft = '0';
tableBlock.style.marginRight = '0';


table.parentNode.insertBefore(tableBlock, table);
table.parentNode.insertBefore(tableBlock, table);
tableBlock.appendChild(table);
tableBlock.appendChild(table);

table.style.boxSizing = 'border-box';
table.style.width = '100%';
table.style.minWidth = '100%';


var controls = document.createElement('div');
var controls = document.createElement('div');
controls.className = 'benchmark-table-controls';
controls.className = 'benchmark-table-controls';
controls.style.boxSizing = 'border-box';


var searchWrapper = document.createElement('div');
var searchWrapper = document.createElement('div');
Line 151: Line 792:
input.className = 'benchmark-search-input';
input.className = 'benchmark-search-input';
input.placeholder =
input.placeholder =
'Search model category, description, or model name';
'Search plugin name, description, or installation source';
input.setAttribute(
input.setAttribute(
'aria-label',
'aria-label',
'Search Example Model Catalogue'
'Search QGIS plugins'
);
);


Line 181: Line 822:


function normaliseText(value) {
function normaliseText(value) {
return value
return String(value || '')
.toLocaleLowerCase()
.toLocaleLowerCase()
.replace(/\s+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
.trim();
}

function clearHighlights(root) {
Array.from(
root.querySelectorAll('mark.search-highlight')
).forEach(function (mark) {
var parent = mark.parentNode;

if (!parent) {
return;
}

parent.replaceChild(
document.createTextNode(mark.textContent),
mark
);

parent.normalize();
});
}

function escapeRegExp(value) {
return String(value).replace(
/[.*+?^${}()|[\]\\]/g,
'\\$&'
);
}

function highlightMatches(root, query) {
var pattern;
var walker;
var matchingNodes = [];
var node;

if (!query) {
return;
}

pattern = new RegExp(
escapeRegExp(query),
'ig'
);

walker = document.createTreeWalker(
root,
NodeFilter.SHOW_TEXT,
{
acceptNode: function (textNode) {
if (
!textNode.nodeValue ||
!textNode.nodeValue.trim()
) {
return NodeFilter.FILTER_REJECT;
}

if (
textNode.parentNode &&
textNode.parentNode.closest(
'mark, script, style, input, button'
)
) {
return NodeFilter.FILTER_REJECT;
}

return pattern.test(textNode.nodeValue)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
}
}
);

while ((node = walker.nextNode())) {
matchingNodes.push(node);
}

matchingNodes.forEach(function (textNode) {
var text = textNode.nodeValue;
var fragment =
document.createDocumentFragment();
var lastIndex = 0;

text.replace(
pattern,
function (match, offset) {
if (offset > lastIndex) {
fragment.appendChild(
document.createTextNode(
text.slice(
lastIndex,
offset
)
)
);
}

var mark =
document.createElement('mark');

mark.className = 'search-highlight';
mark.textContent = match;
fragment.appendChild(mark);

lastIndex = offset + match.length;

return match;
}
);

if (lastIndex < text.length) {
fragment.appendChild(
document.createTextNode(
text.slice(lastIndex)
)
);
}

textNode.parentNode.replaceChild(
fragment,
textNode
);
});
}
}


function filterRows() {
function filterRows() {
var query = normaliseText(input.value);
var rawQuery = input.value.trim();
var visibleCount = 0;
var visibleCount = 0;


rows.forEach(function (row) {
rows.forEach(function (row) {
var matches =
tuflowClearHighlights(row);

query === '' ||
normaliseText(row.textContent).includes(query);
var matches = tuflowMatchesSearch(
row.textContent,
rawQuery
);


row.hidden = !matches;
row.hidden = !matches;
Line 200: Line 965:
if (matches) {
if (matches) {
visibleCount += 1;
visibleCount += 1;

if (rawQuery) {
tuflowHighlightMatches(row, rawQuery);
}
}
}
});
});


resultCount.textContent = query
resultCount.textContent = rawQuery
? visibleCount +
? visibleCount +
' matching model' +
' matching plugin' +
(visibleCount === 1 ? '' : 's')
(visibleCount === 1 ? '' : 's')
: rows.length + ' example models';
: rows.length + ' QGIS plugins';


clearButton.hidden = query === '';
clearButton.hidden = rawQuery === '';
}
}


input.addEventListener('input', filterRows);
input.addEventListener('input', filterRows);


clearButton.addEventListener('click', function () {
clearButton.addEventListener(
input.value = '';
'click',
filterRows();
function () {
input.focus();
input.value = '';
});
filterRows();
input.focus();
}
);


filterRows();
filterRows();

Revision as of 15:00, 16 July 2026

/* Any JavaScript here will be loaded for all users on every page load. */

/* -------------------------------------------------------------------------
 * Shared helpers
 * ------------------------------------------------------------------------- */

function tuflowNormaliseSearchText(value) {
    return String(value || '')
        .toLocaleLowerCase()
        .replace(/\s+/g, ' ')
        .trim();
}


/*
 * Parse a query into OR groups containing AND terms.
 *
 * Examples:
 * raster terrain
 * raster AND terrain
 * QuickOSM OR HCMGIS
 * "repository install" AND raster
 */
function tuflowParseSearchQuery(rawQuery) {
    var query = String(rawQuery || '').trim();

    if (!query) {
        return [];
    }

    return query.split(/\s+OR\s+/i).map(function (group) {
        var terms = [];
        var pattern = /"([^"]+)"|(\S+)/g;
        var match;

        while ((match = pattern.exec(group)) !== null) {
            var term = match[1] || match[2];

            if (!/^AND$/i.test(term)) {
                term = tuflowNormaliseSearchText(term);

                if (term) {
                    terms.push(term);
                }
            }
        }

        return terms;
    }).filter(function (group) {
        return group.length > 0;
    });
}

function tuflowMatchesSearch(text, rawQuery) {
    var normalisedText = tuflowNormaliseSearchText(text);
    var groups = tuflowParseSearchQuery(rawQuery);

    if (!groups.length) {
        return true;
    }

    return groups.some(function (terms) {
        return terms.every(function (term) {
            return normalisedText.includes(term);
        });
    });
}

function tuflowGetSearchTerms(rawQuery) {
    var terms = [];

    tuflowParseSearchQuery(rawQuery).forEach(function (group) {
        group.forEach(function (term) {
            if (terms.indexOf(term) === -1) {
                terms.push(term);
            }
        });
    });

    return terms.sort(function (a, b) {
        return b.length - a.length;
    });
}

function tuflowEscapeRegExp(value) {
    return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function tuflowClearHighlights(root) {
    if (!root) {
        return;
    }

    Array.from(root.querySelectorAll('mark.search-highlight')).forEach(function (mark) {
        var textNode = document.createTextNode(mark.textContent);
        var parent = mark.parentNode;

        if (!parent) {
            return;
        }

        parent.replaceChild(textNode, mark);
        parent.normalize();
    });
}

function tuflowHighlightMatches(root, rawQuery) {
    if (!root || !rawQuery) {
        return;
    }

    tuflowClearHighlights(root);

    var terms = tuflowGetSearchTerms(rawQuery);

    if (!terms.length) {
        return;
    }

    /*
     * Longer terms are checked first so that phrases such as
     * "repository install" are highlighted before "repository".
     */
    terms.sort(function (a, b) {
        return b.length - a.length;
    });

    var escapedTerms = terms.map(function (term) {
        return tuflowEscapeRegExp(term);
    });

    var testPattern = new RegExp(
        escapedTerms.join('|'),
        'i'
    );

    var replacePattern = new RegExp(
        escapedTerms.join('|'),
        'ig'
    );

    var walker = document.createTreeWalker(
        root,
        NodeFilter.SHOW_TEXT,
        {
            acceptNode: function (node) {
                if (!node.nodeValue || !node.nodeValue.trim()) {
                    return NodeFilter.FILTER_REJECT;
                }

                if (
                    node.parentNode &&
                    [
                        'SCRIPT',
                        'STYLE',
                        'MARK',
                        'TEXTAREA',
                        'INPUT',
                        'BUTTON'
                    ].indexOf(node.parentNode.nodeName) !== -1
                ) {
                    return NodeFilter.FILTER_REJECT;
                }

                return testPattern.test(node.nodeValue)
                    ? NodeFilter.FILTER_ACCEPT
                    : NodeFilter.FILTER_REJECT;
            }
        }
    );

    var textNodes = [];
    var currentNode;

    while ((currentNode = walker.nextNode())) {
        textNodes.push(currentNode);
    }

    textNodes.forEach(function (node) {
        var text = node.nodeValue;
        var fragment = document.createDocumentFragment();
        var lastIndex = 0;

        text.replace(
            replacePattern,
            function (match, offset) {
                if (offset > lastIndex) {
                    fragment.appendChild(
                        document.createTextNode(
                            text.slice(lastIndex, offset)
                        )
                    );
                }

                var normalisedMatch =
                    tuflowNormaliseSearchText(match);

                var termIndex = terms.findIndex(function (term) {
                    return term === normalisedMatch;
                });

                /*
                 * Cycle through five highlight colours.
                 */
                var colourNumber =
                    (Math.max(termIndex, 0) % 5) + 1;

                var mark = document.createElement('mark');

                mark.className =
                    'search-highlight search-highlight-' +
                    colourNumber;

                mark.textContent = match;
                fragment.appendChild(mark);

                lastIndex = offset + match.length;

                return match;
            }
        );

        if (lastIndex < text.length) {
            fragment.appendChild(
                document.createTextNode(
                    text.slice(lastIndex)
                )
            );
        }

        if (node.parentNode) {
            node.parentNode.replaceChild(fragment, node);
        }
    });
}

/* -------------------------------------------------------------------------
 * Hardware benchmark table search
 * ------------------------------------------------------------------------- */

mw.hook('wikipage.content').add(function ($content) {
    'use strict';

    var action = mw.config.get('wgAction');

    /* Do not run inside edit or preview screens */
    if (action === 'edit' || action === 'submit') {
        return;
    }

    $content.find('.benchmark-results-table').each(function () {
        var table = this;

        if (table.dataset.searchInitialised === 'true') {
            return;
        }

        table.dataset.searchInitialised = 'true';

        var tableBlock = document.createElement('div');
        tableBlock.className = 'benchmark-table-block';

        table.parentNode.insertBefore(tableBlock, table);
        tableBlock.appendChild(table);

        var controls = document.createElement('div');
        controls.className = 'benchmark-table-controls';

        var searchWrapper = document.createElement('div');
        searchWrapper.className = 'benchmark-table-search';

        var input = document.createElement('input');
        input.type = 'search';
        input.className = 'benchmark-search-input';
        input.placeholder =
            'Search processor, system name, RAM, or benchmark result';
        input.setAttribute('aria-label', 'Search benchmark results');

        var clearButton = document.createElement('button');
        clearButton.type = 'button';
        clearButton.className = 'benchmark-search-clear';
        clearButton.textContent = 'Clear';

        var resultCount = document.createElement('div');
        resultCount.className = 'benchmark-result-count';
        resultCount.setAttribute('aria-live', 'polite');

        searchWrapper.appendChild(input);
        searchWrapper.appendChild(clearButton);

        controls.appendChild(searchWrapper);
        controls.appendChild(resultCount);

        tableBlock.insertBefore(controls, table);

        var rows = Array.from(table.querySelectorAll('tr')).filter(function (row) {
            return row.querySelectorAll('td').length > 0;
        });

        function filterRows() {
            var rawQuery = input.value.trim();
            var visibleCount = 0;

            rows.forEach(function (row) {
                tuflowClearHighlights(row);

                var matches = tuflowMatchesSearch(
                    row.textContent,
                    rawQuery
                );

                row.hidden = !matches;

                if (matches) {
                    visibleCount += 1;

                    if (rawQuery !== '') {
                        tuflowHighlightMatches(row, rawQuery);
                    }
                }
            });

            resultCount.textContent = rawQuery
                ? visibleCount + ' matching result' + (visibleCount === 1 ? '' : 's')
                : rows.length + ' benchmark results';

            clearButton.hidden = rawQuery === '';
        }

        input.addEventListener('input', filterRows);

        clearButton.addEventListener('click', function () {
            input.value = '';
            filterRows();
            input.focus();
        });

        filterRows();
    });
});


/* -------------------------------------------------------------------------
 * Category-aware Example Model Catalogue search
 * ------------------------------------------------------------------------- */

mw.hook('wikipage.content').add(function ($content) {
    'use strict';

    var action = mw.config.get('wgAction');

    /* Allow preview testing, but not the initial edit screen */
    if (action === 'edit') {
        return;
    }

    $content.find('.example-model-results-table').each(function () {
        var table = this;

        if (table.dataset.exampleSearchInitialised === 'true') {
            return;
        }

        table.dataset.exampleSearchInitialised = 'true';

        /*
         * Keep the search controls outside the responsive table wrapper.
         * This prevents the search field from being clipped on desktop.
         */
        var tableWrapper = table.closest('.example-model-table-wrapper');

        var tableBlock = document.createElement('div');
        tableBlock.className =
            'benchmark-table-block example-model-table-block';

        tableBlock.style.display = 'block';
        tableBlock.style.width = '100%';
        tableBlock.style.maxWidth = '100%';
        tableBlock.style.marginLeft = '0';
        tableBlock.style.marginRight = '0';

        if (tableWrapper && tableWrapper.parentNode) {
            tableWrapper.parentNode.insertBefore(
                tableBlock,
                tableWrapper
            );

            tableBlock.appendChild(tableWrapper);
        } else {
            table.parentNode.insertBefore(
                tableBlock,
                table
            );

            tableBlock.appendChild(table);
        }

        table.style.boxSizing = 'border-box';
        table.style.width = '100%';
        table.style.minWidth = '100%';

        var controls = document.createElement('div');
        controls.className =
            'benchmark-table-controls example-model-table-controls';
        controls.style.boxSizing = 'border-box';

        var searchWrapper = document.createElement('div');
        searchWrapper.className = 'benchmark-table-search';

        var input = document.createElement('input');
        input.type = 'search';
        input.className = 'benchmark-search-input';
        input.placeholder =
            'Search model category, description, or model name';
        input.setAttribute(
            'aria-label',
            'Search Example Model Catalogue'
        );

        var clearButton = document.createElement('button');
        clearButton.type = 'button';
        clearButton.className = 'benchmark-search-clear';
        clearButton.textContent = 'Clear';

        var resultCount = document.createElement('div');
        resultCount.className = 'benchmark-result-count';
        resultCount.setAttribute('aria-live', 'polite');

        searchWrapper.appendChild(input);
        searchWrapper.appendChild(clearButton);

        controls.appendChild(searchWrapper);
        controls.appendChild(resultCount);

        if (tableWrapper) {
            tableBlock.insertBefore(
                controls,
                tableWrapper
            );
        } else {
            tableBlock.insertBefore(
                controls,
                table
            );
        }

        /*
         * Catalogue logical columns:
         * 0: Major category
         * 1: Subcategory
         * 2: Description
         * 3: Model name
         */
        var originalRows = Array.from(
            table.querySelectorAll('tr')
        ).filter(function (row) {
            return row.querySelectorAll('td').length > 0;
        });

        if (!originalRows.length) {
            controls.remove();
            return;
        }

        var rowParent = originalRows[0].parentNode;
        var activeRowspans = [];
        var records = [];

        function getCellText(cell) {
            return cell
                ? tuflowNormaliseSearchText(cell.textContent)
                : '';
        }

        originalRows.forEach(function (row, rowIndex) {
            var logicalCells = [];
            var physicalCells = Array.from(row.cells);
            var columnIndex = 0;

            activeRowspans.forEach(function (active, index) {
                if (active && active.endRow >= rowIndex) {
                    logicalCells[index] = active.cell;
                }
            });

            physicalCells.forEach(function (cell) {
                while (logicalCells[columnIndex]) {
                    columnIndex += 1;
                }

                var colspan = Math.max(cell.colSpan || 1, 1);
                var rowspan = Math.max(cell.rowSpan || 1, 1);

                for (
                    var offset = 0;
                    offset < colspan;
                    offset += 1
                ) {
                    var logicalColumn = columnIndex + offset;

                    logicalCells[logicalColumn] = cell;

                    if (rowspan > 1) {
                        activeRowspans[logicalColumn] = {
                            cell: cell,
                            endRow: rowIndex + rowspan - 1
                        };
                    }
                }

                columnIndex += colspan;
            });

            var categoryCell = logicalCells[0] || null;
            var subcategoryCell = logicalCells[1] || null;
            var descriptionCell = logicalCells[2] || null;
            var modelNameCell = logicalCells[3] || null;

            var categorySpansBoth =
                categoryCell &&
                subcategoryCell &&
                categoryCell === subcategoryCell;

            var category = getCellText(categoryCell);
            var subcategory = categorySpansBoth
                ? ''
                : getCellText(subcategoryCell);
            var description = getCellText(descriptionCell);
            var modelName = getCellText(modelNameCell);

            records.push({
                originalRow: row,
                categoryCell: categoryCell,
                subcategoryCell: categorySpansBoth
                    ? null
                    : subcategoryCell,
                descriptionCell: descriptionCell,
                modelNameCell: modelNameCell,
                category: category,
                subcategory: subcategory,
                categoryKey:
                    category || '__uncategorised__',
                subcategoryKey:
                    subcategory || '__no_subcategory__',
                searchableText: tuflowNormaliseSearchText(
                    [
                        category,
                        subcategory,
                        description,
                        modelName
                    ].join(' ')
                )
            });
        });

        function cloneCell(cell) {
            var clone;

            if (!cell) {
                return document.createElement('td');
            }

            clone = cell.cloneNode(true);
            clone.removeAttribute('rowspan');
            clone.removeAttribute('colspan');

            return clone;
        }

        function removeDisplayedDataRows() {
            Array.from(
                table.querySelectorAll('tr')
            ).forEach(function (row) {
                if (row.querySelectorAll('td').length > 0) {
                    row.remove();
                }
            });
        }

        function restoreOriginalRows() {
            removeDisplayedDataRows();

            records.forEach(function (record) {
                tuflowClearHighlights(record.originalRow);
                rowParent.appendChild(record.originalRow);
            });
        }

        function getRunLength(
            items,
            startIndex,
            keyFunction
        ) {
            var startingKey =
                keyFunction(items[startIndex]);
            var count = 1;
            var index;

            for (
                index = startIndex + 1;
                index < items.length;
                index += 1
            ) {
                if (
                    keyFunction(items[index]) !== startingKey
                ) {
                    break;
                }

                count += 1;
            }

            return count;
        }

        function renderFilteredRows(matches, query) {
            removeDisplayedDataRows();

            matches.forEach(function (record, index) {
                var previous =
                    index > 0 ? matches[index - 1] : null;

                var categoryStarts =
                    !previous ||
                    previous.categoryKey !==
                        record.categoryKey;

                var subcategoryStarts =
                    categoryStarts ||
                    previous.subcategoryKey !==
                        record.subcategoryKey;

                var row = document.createElement('tr');

                if (categoryStarts) {
                    var categoryCell =
                        cloneCell(record.categoryCell);

                    var categoryRowspan = getRunLength(
                        matches,
                        index,
                        function (item) {
                            return item.categoryKey;
                        }
                    );

                    categoryCell.rowSpan = categoryRowspan;

                    if (!record.subcategory) {
                        categoryCell.colSpan = 2;
                    }

                    row.appendChild(categoryCell);
                }

                if (
                    record.subcategory &&
                    subcategoryStarts
                ) {
                    var subcategoryCell =
                        cloneCell(record.subcategoryCell);

                    var subcategoryRowspan = getRunLength(
                        matches,
                        index,
                        function (item) {
                            return (
                                item.categoryKey +
                                '::' +
                                item.subcategoryKey
                            );
                        }
                    );

                    subcategoryCell.rowSpan =
                        subcategoryRowspan;

                    row.appendChild(subcategoryCell);
                }

                row.appendChild(
                    cloneCell(record.descriptionCell)
                );

                row.appendChild(
                    cloneCell(record.modelNameCell)
                );

                if (query) {
                    tuflowHighlightMatches(row, query);
                }

                rowParent.appendChild(row);
            });
        }

        function filterRows() {
            var rawQuery = input.value.trim();
            var matches;

            if (!rawQuery) {
                restoreOriginalRows();

                resultCount.textContent =
                    records.length + ' example models';

                clearButton.hidden = true;
                return;
            }

            matches = records.filter(function (record) {
                return tuflowMatchesSearch(
                    record.searchableText,
                    rawQuery
                );
            });

            renderFilteredRows(matches, rawQuery);

            resultCount.textContent =
                matches.length +
                ' matching model' +
                (matches.length === 1 ? '' : 's');

            clearButton.hidden = false;
        }

        input.addEventListener('input', filterRows);

        clearButton.addEventListener(
            'click',
            function () {
                input.value = '';
                filterRows();
                input.focus();
            }
        );

        filterRows();
    });
});

/* -------------------------------------------------------------------------
 * QGIS Useful Plugins table search
 * ------------------------------------------------------------------------- */

mw.hook('wikipage.content').add(function ($content) {
    'use strict';

    var action = mw.config.get('wgAction');

    /* Allow preview testing, but not the initial edit screen */
    if (action === 'edit') {
        return;
    }

    $content.find('.qgis-plugin-results-table').each(function () {
        var table = this;

        if (table.dataset.pluginSearchInitialised === 'true') {
            return;
        }

        table.dataset.pluginSearchInitialised = 'true';

        var tableBlock = document.createElement('div');
        tableBlock.className =
            'benchmark-table-block qgis-plugin-table-block';

        tableBlock.style.display = 'block';
        tableBlock.style.width = '100%';
        tableBlock.style.maxWidth = '100%';
        tableBlock.style.marginLeft = '0';
        tableBlock.style.marginRight = '0';

        table.parentNode.insertBefore(tableBlock, table);
        tableBlock.appendChild(table);

        table.style.boxSizing = 'border-box';
        table.style.width = '100%';
        table.style.minWidth = '100%';

        var controls = document.createElement('div');
        controls.className = 'benchmark-table-controls';
        controls.style.boxSizing = 'border-box';

        var searchWrapper = document.createElement('div');
        searchWrapper.className = 'benchmark-table-search';

        var input = document.createElement('input');
        input.type = 'search';
        input.className = 'benchmark-search-input';
        input.placeholder =
            'Search plugin name, description, or installation source';
        input.setAttribute(
            'aria-label',
            'Search QGIS plugins'
        );

        var clearButton = document.createElement('button');
        clearButton.type = 'button';
        clearButton.className = 'benchmark-search-clear';
        clearButton.textContent = 'Clear';

        var resultCount = document.createElement('div');
        resultCount.className = 'benchmark-result-count';
        resultCount.setAttribute('aria-live', 'polite');

        searchWrapper.appendChild(input);
        searchWrapper.appendChild(clearButton);

        controls.appendChild(searchWrapper);
        controls.appendChild(resultCount);

        tableBlock.insertBefore(controls, table);

        var rows = Array.from(
            table.querySelectorAll('tr')
        ).filter(function (row) {
            return row.querySelectorAll('td').length > 0;
        });

        function normaliseText(value) {
            return String(value || '')
                .toLocaleLowerCase()
                .replace(/\s+/g, ' ')
                .trim();
        }

        function clearHighlights(root) {
            Array.from(
                root.querySelectorAll('mark.search-highlight')
            ).forEach(function (mark) {
                var parent = mark.parentNode;

                if (!parent) {
                    return;
                }

                parent.replaceChild(
                    document.createTextNode(mark.textContent),
                    mark
                );

                parent.normalize();
            });
        }

        function escapeRegExp(value) {
            return String(value).replace(
                /[.*+?^${}()|[\]\\]/g,
                '\\$&'
            );
        }

        function highlightMatches(root, query) {
            var pattern;
            var walker;
            var matchingNodes = [];
            var node;

            if (!query) {
                return;
            }

            pattern = new RegExp(
                escapeRegExp(query),
                'ig'
            );

            walker = document.createTreeWalker(
                root,
                NodeFilter.SHOW_TEXT,
                {
                    acceptNode: function (textNode) {
                        if (
                            !textNode.nodeValue ||
                            !textNode.nodeValue.trim()
                        ) {
                            return NodeFilter.FILTER_REJECT;
                        }

                        if (
                            textNode.parentNode &&
                            textNode.parentNode.closest(
                                'mark, script, style, input, button'
                            )
                        ) {
                            return NodeFilter.FILTER_REJECT;
                        }

                        return pattern.test(textNode.nodeValue)
                            ? NodeFilter.FILTER_ACCEPT
                            : NodeFilter.FILTER_REJECT;
                    }
                }
            );

            while ((node = walker.nextNode())) {
                matchingNodes.push(node);
            }

            matchingNodes.forEach(function (textNode) {
                var text = textNode.nodeValue;
                var fragment =
                    document.createDocumentFragment();
                var lastIndex = 0;

                text.replace(
                    pattern,
                    function (match, offset) {
                        if (offset > lastIndex) {
                            fragment.appendChild(
                                document.createTextNode(
                                    text.slice(
                                        lastIndex,
                                        offset
                                    )
                                )
                            );
                        }

                        var mark =
                            document.createElement('mark');

                        mark.className = 'search-highlight';
                        mark.textContent = match;
                        fragment.appendChild(mark);

                        lastIndex = offset + match.length;

                        return match;
                    }
                );

                if (lastIndex < text.length) {
                    fragment.appendChild(
                        document.createTextNode(
                            text.slice(lastIndex)
                        )
                    );
                }

                textNode.parentNode.replaceChild(
                    fragment,
                    textNode
                );
            });
        }

        function filterRows() {
            var rawQuery = input.value.trim();
            var visibleCount = 0;

            rows.forEach(function (row) {
                tuflowClearHighlights(row);

                var matches = tuflowMatchesSearch(
                    row.textContent,
                    rawQuery
                );

                row.hidden = !matches;

                if (matches) {
                    visibleCount += 1;

                    if (rawQuery) {
                        tuflowHighlightMatches(row, rawQuery);
                    }
                }
            });

            resultCount.textContent = rawQuery
                ? visibleCount +
                  ' matching plugin' +
                  (visibleCount === 1 ? '' : 's')
                : rows.length + ' QGIS plugins';

            clearButton.hidden = rawQuery === '';
        }

        input.addEventListener('input', filterRows);

        clearButton.addEventListener(
            'click',
            function () {
                input.value = '';
                filterRows();
                input.focus();
            }
        );

        filterRows();
    });
});