MediaWiki:Common.js: Difference between revisions

From Tuflow
Jump to navigation Jump to search
Content deleted Content added
No edit summary
No edit summary
(5 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. */

/* -------------------------------------------------------------------------
* 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);
}
});
}


/* -------------------------------------------------------------------------
/* -------------------------------------------------------------------------
Line 24: 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 42: 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 64: 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 String(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 90: 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 124: Line 350:
var action = mw.config.get('wgAction');
var action = mw.config.get('wgAction');


/* Do not rearrange the table in edit or preview screens */
/* Allow preview testing, but not the initial edit screen */
if (action === 'edit') {
if (action === 'edit') {
return;
return;
Line 139: Line 365:


/*
/*
* Reuse the hardware-search classes so both search bars have
* Keep the search controls outside the responsive table wrapper.
* the same visual appearance.
* 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 =
tableBlock.className =
'benchmark-table-block example-model-table-block';
'benchmark-table-block example-model-table-block';


tableBlock.style.display = 'block';
table.parentNode.insertBefore(tableBlock, table);
tableBlock.appendChild(table);
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');
var controls = document.createElement('div');
controls.className = 'benchmark-table-controls';
controls.className =
'benchmark-table-controls example-model-table-controls';
controls.style.boxSizing = 'border-box';


var searchWrapper = document.createElement('div');
var searchWrapper = document.createElement('div');
Line 180: Line 433:
controls.appendChild(resultCount);
controls.appendChild(resultCount);


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


/*
/*
* Catalogue logical columns:
* Catalogue logical columns:
*
* 0: Major category
* 0: Major category
* 1: Subcategory
* 1: Subcategory
* 2: Description
* 2: Description
* 3: Model name
* 3: Model name
*
* Major categories and subcategories may use rowspan.
* Some major categories may span both category columns.
*/
*/
var originalRows = Array.from(
var originalRows = Array.from(
Line 207: Line 466:
var activeRowspans = [];
var activeRowspans = [];
var records = [];
var records = [];

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


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


/*
* Convert each physical HTML row into a logical catalogue record.
* Text from merged category cells is inherited by every row covered
* by that rowspan.
*/
originalRows.forEach(function (row, rowIndex) {
originalRows.forEach(function (row, rowIndex) {
var logicalCells = [];
var logicalCells = [];
Line 270: Line 517:
var modelNameCell = logicalCells[3] || null;
var modelNameCell = logicalCells[3] || null;


/*
* When one cell spans both category columns, there is no
* separate subcategory.
*/
var categorySpansBoth =
var categorySpansBoth =
categoryCell &&
categoryCell &&
Line 300: Line 543:
subcategoryKey:
subcategoryKey:
subcategory || '__no_subcategory__',
subcategory || '__no_subcategory__',
searchableText: normaliseText(
searchableText: tuflowNormaliseSearchText(
[
[
category,
category,
Line 339: Line 582:


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


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;
Line 353: Line 602:
index += 1
index += 1
) {
) {
if (keyFunction(items[index]) !== startingKey) {
if (
keyFunction(items[index]) !== startingKey
) {
break;
break;
}
}
Line 363: Line 614:
}
}


function renderFilteredRows(matches, query) {
/*
* Rebuild only the matching rows and calculate new rowspan values
* for the visible category and subcategory groups.
*/
function renderFilteredRows(matches) {
removeDisplayedDataRows();
removeDisplayedDataRows();


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


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


var subcategoryStarts =
var subcategoryStarts =
Line 439: Line 686:
cloneCell(record.modelNameCell)
cloneCell(record.modelNameCell)
);
);

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


rowParent.appendChild(row);
rowParent.appendChild(row);
Line 445: Line 696:


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


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


Line 459: Line 710:


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


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


resultCount.textContent =
resultCount.textContent =
Line 474: 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();
});
});

/* -------------------------------------------------------------------------
* 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();
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();
    });
});