MediaWiki:Common.js: Difference between revisions

From Tuflow
Jump to navigation Jump to search
Content deleted Content added
No edit summary
No edit summary
(4 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 139: Line 365:


/*
/*
* Reuse the hardware-search classes so both search bars have
* Keep the search controls outside the responsive table wrapper.
* the same 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';


/*
* Keep the catalogue full width even when no rows match.
*/
tableBlock.style.display = 'block';
tableBlock.style.display = 'block';
tableBlock.style.width = '100%';
tableBlock.style.width = '100%';
Line 155: 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 163: 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 194: 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
Line 218: 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 row into a logical catalogue record.
* Merged category text is inherited by each associated row.
*/
originalRows.forEach(function (row, rowIndex) {
originalRows.forEach(function (row, rowIndex) {
var logicalCells = [];
var logicalCells = [];
Line 306: Line 543:
subcategoryKey:
subcategoryKey:
subcategory || '__no_subcategory__',
subcategory || '__no_subcategory__',
searchableText: normaliseText(
searchableText: tuflowNormaliseSearchText(
[
[
category,
category,
Line 345: 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 359: Line 602:
index += 1
index += 1
) {
) {
if (keyFunction(items[index]) !== startingKey) {
if (
keyFunction(items[index]) !== startingKey
) {
break;
break;
}
}
Line 369: Line 614:
}
}


function renderFilteredRows(matches, query) {
/*
* Rebuild matching rows and recalculate category rowspans.
*/
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 444: Line 686:
cloneCell(record.modelNameCell)
cloneCell(record.modelNameCell)
);
);

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


rowParent.appendChild(row);
rowParent.appendChild(row);
Line 450: 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 464: 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 479: 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();
    });
});