MediaWiki:Common.js: Difference between revisions

From Tuflow
Jump to navigation Jump to search
Content deleted Content added
No edit summary
No edit summary
Tag: Manual revert
 
(5 intermediate revisions by the same user not shown)
Line 10: Line 10:
.replace(/\s+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
.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;
});
}
}


Line 34: Line 105:
}
}


function tuflowHighlightMatches(root, query) {
function tuflowHighlightMatches(root, rawQuery) {
if (!root || !query) {
if (!root || !rawQuery) {
return;
return;
}
}
Line 41: Line 112:
tuflowClearHighlights(root);
tuflowClearHighlights(root);


var terms = tuflowGetSearchTerms(rawQuery);
var testPattern = new RegExp(tuflowEscapeRegExp(query), 'i');

var replacePattern = new RegExp(tuflowEscapeRegExp(query), 'ig');
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(
var walker = document.createTreeWalker(
Line 55: Line 151:
if (
if (
node.parentNode &&
node.parentNode &&
[
['SCRIPT', 'STYLE', 'MARK', 'TEXTAREA', 'INPUT'].indexOf(node.parentNode.nodeName) !== -1
'SCRIPT',
'STYLE',
'MARK',
'TEXTAREA',
'INPUT',
'BUTTON'
].indexOf(node.parentNode.nodeName) !== -1
) {
) {
return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_REJECT;
Line 79: Line 182:
var lastIndex = 0;
var lastIndex = 0;


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


var mark = document.createElement('mark');
var normalisedMatch =
mark.className = 'search-highlight';
tuflowNormaliseSearchText(match);
mark.textContent = match;
fragment.appendChild(mark);


lastIndex = offset + match.length;
var termIndex = terms.findIndex(function (term) {
return match;
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) {
if (lastIndex < text.length) {
fragment.appendChild(
fragment.appendChild(
document.createTextNode(text.slice(lastIndex))
document.createTextNode(
text.slice(lastIndex)
)
);
);
}
}
Line 106: Line 234:
});
});
}
}



/* -------------------------------------------------------------------------
/* -------------------------------------------------------------------------
Line 172: Line 299:


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


Line 178: Line 305:
tuflowClearHighlights(row);
tuflowClearHighlights(row);


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


row.hidden = !matches;
row.hidden = !matches;
Line 187: Line 315:
visibleCount += 1;
visibleCount += 1;


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


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


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


Line 235: Line 363:


table.dataset.exampleSearchInitialised = 'true';
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');
var tableBlock = document.createElement('div');
Line 246: Line 380:
tableBlock.style.marginRight = '0';
tableBlock.style.marginRight = '0';


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

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

tableBlock.appendChild(table);
}


table.style.boxSizing = 'border-box';
table.style.boxSizing = 'border-box';
Line 254: Line 401:


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


Line 265: Line 413:
input.placeholder =
input.placeholder =
'Search model category, description, or model name';
'Search model category, description, or model name';
input.setAttribute('aria-label', 'Search Example Model Catalogue');
input.setAttribute(
'aria-label',
'Search Example Model Catalogue'
);


var clearButton = document.createElement('button');
var clearButton = document.createElement('button');
Line 282: Line 433:
controls.appendChild(resultCount);
controls.appendChild(resultCount);


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


/*
/*
Line 291: Line 452:
* 3: Model name
* 3: Model name
*/
*/
var originalRows = Array.from(table.querySelectorAll('tr')).filter(function (row) {
var originalRows = Array.from(
table.querySelectorAll('tr')
).filter(function (row) {
return row.querySelectorAll('td').length > 0;
return row.querySelectorAll('td').length > 0;
});
});
Line 305: Line 468:


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


Line 327: Line 492:
var rowspan = Math.max(cell.rowSpan || 1, 1);
var rowspan = Math.max(cell.rowSpan || 1, 1);


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


Line 354: Line 523:


var category = getCellText(categoryCell);
var category = getCellText(categoryCell);
var subcategory = categorySpansBoth ? '' : getCellText(subcategoryCell);
var subcategory = categorySpansBoth
? ''
: getCellText(subcategoryCell);
var description = getCellText(descriptionCell);
var description = getCellText(descriptionCell);
var modelName = getCellText(modelNameCell);
var modelName = getCellText(modelNameCell);
Line 361: Line 532:
originalRow: row,
originalRow: row,
categoryCell: categoryCell,
categoryCell: categoryCell,
subcategoryCell: categorySpansBoth ? null : subcategoryCell,
subcategoryCell: categorySpansBoth
? null
: subcategoryCell,
descriptionCell: descriptionCell,
descriptionCell: descriptionCell,
modelNameCell: modelNameCell,
modelNameCell: modelNameCell,
category: category,
category: category,
subcategory: subcategory,
subcategory: subcategory,
categoryKey: category || '__uncategorised__',
categoryKey:
subcategoryKey: subcategory || '__no_subcategory__',
category || '__uncategorised__',
subcategoryKey:
subcategory || '__no_subcategory__',
searchableText: tuflowNormaliseSearchText(
searchableText: tuflowNormaliseSearchText(
[category, subcategory, description, modelName].join(' ')
[
category,
subcategory,
description,
modelName
].join(' ')
)
)
});
});
Line 389: Line 569:


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


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


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


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


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


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


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


if (categoryStarts) {
if (categoryStarts) {
var categoryCell = cloneCell(record.categoryCell);
var categoryCell =
var categoryRowspan = getRunLength(matches, index, function (item) {
cloneCell(record.categoryCell);

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


categoryCell.rowSpan = categoryRowspan;
categoryCell.rowSpan = categoryRowspan;
Line 452: Line 654:
}
}


if (record.subcategory && subcategoryStarts) {
if (
var subcategoryCell = cloneCell(record.subcategoryCell);
record.subcategory &&
var subcategoryRowspan = getRunLength(matches, index, function (item) {
subcategoryStarts
return item.categoryKey + '::' + item.subcategoryKey;
) {
});
var subcategoryCell =
cloneCell(record.subcategoryCell);

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

subcategoryCell.rowSpan =
subcategoryRowspan;


subcategoryCell.rowSpan = subcategoryRowspan;
row.appendChild(subcategoryCell);
row.appendChild(subcategoryCell);
}
}


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

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


if (query) {
if (query) {
Line 474: Line 696:


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


if (!query) {
if (!rawQuery) {
restoreOriginalRows();
restoreOriginalRows();


Line 488: Line 710:


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


renderFilteredRows(matches, query);
renderFilteredRows(matches, rawQuery);


resultCount.textContent =
resultCount.textContent =
Line 503: Line 728:
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();
Line 722: Line 950:


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) {
clearHighlights(row);
tuflowClearHighlights(row);


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


row.hidden = !matches;
row.hidden = !matches;
Line 739: Line 966:
visibleCount += 1;
visibleCount += 1;


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


resultCount.textContent = query
resultCount.textContent = rawQuery
? visibleCount +
? visibleCount +
' matching plugin' +
' matching plugin' +
Line 751: Line 978:
: rows.length + ' QGIS plugins';
: rows.length + ' QGIS plugins';


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



Latest revision as of 10:39, 17 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();
    });
});