MediaWiki:Common.js: Difference between revisions

From Tuflow
Jump to navigation Jump to search
Content deleted Content added
No edit summary
No edit summary
Tag: Manual revert
 
(11 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();
});
});
});
});

/* -------------------------------------------------------------------------
* TUFLOW Wiki dark mode toggle
* ------------------------------------------------------------------------- */

(function () {
'use strict';

function initialiseDarkModeToggle() {
if (document.getElementById('tuflow-dark-mode-toggle')) {
return;
}

var savedMode = localStorage.getItem('tuflowDarkMode');
var darkModeEnabled = savedMode === 'dark';

var link = document.createElement('a');
link.id = 'tuflow-dark-mode-toggle';
link.href = '#';
link.textContent = darkModeEnabled
? '☀ Light mode'
: '☾ Dark mode';

link.setAttribute(
'aria-label',
darkModeEnabled
? 'Turn off dark mode'
: 'Turn on dark mode'
);

function applyDarkMode(enabled) {
document.documentElement.classList.toggle(
'tuflow-dark-mode',
enabled
);

link.textContent = enabled
? '☀ Light mode'
: '☾ Dark mode';

link.setAttribute(
'aria-label',
enabled
? 'Turn off dark mode'
: 'Turn on dark mode'
);
}

link.addEventListener('click', function (event) {
event.preventDefault();

darkModeEnabled =
!document.documentElement.classList.contains(
'tuflow-dark-mode'
);

localStorage.setItem(
'tuflowDarkMode',
darkModeEnabled ? 'dark' : 'light'
);

applyDarkMode(darkModeEnabled);
});

var listItem = document.createElement('li');
listItem.id = 'pt-tuflow-dark-mode';
listItem.appendChild(link);

var personalList =
document.querySelector('#p-personal ul') ||
document.querySelector('#p-personal .vector-menu-content-list') ||
document.querySelector('.vector-user-links ul');

if (personalList) {
personalList.appendChild(listItem);
} else {
var fallback = document.createElement('div');

fallback.id = 'tuflow-dark-mode-fallback';
fallback.appendChild(link);

document.body.appendChild(fallback);
}

applyDarkMode(darkModeEnabled);
}

if (document.readyState === 'loading') {
document.addEventListener(
'DOMContentLoaded',
initialiseDarkModeToggle
);
} else {
initialiseDarkModeToggle();
}
})();

Latest revision as of 10:54, 17 July 2026

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

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

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


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

    if (!query) {
        return [];
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        if (!parent) {
            return;
        }

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

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

    tuflowClearHighlights(root);

    var terms = tuflowGetSearchTerms(rawQuery);

    if (!terms.length) {
        return;
    }

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

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

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

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

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

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

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

    var textNodes = [];
    var currentNode;

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

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

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

                var normalisedMatch =
                    tuflowNormaliseSearchText(match);

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

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

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

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

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

                lastIndex = offset + match.length;

                return match;
            }
        );

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

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

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

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

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

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

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

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

        table.dataset.searchInitialised = 'true';

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

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

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

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

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

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

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

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

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

        tableBlock.insertBefore(controls, table);

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

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

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

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

                row.hidden = !matches;

                if (matches) {
                    visibleCount += 1;

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

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

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

        input.addEventListener('input', filterRows);

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

        filterRows();
    });
});


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

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

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

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

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

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

        table.dataset.exampleSearchInitialised = 'true';

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

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

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

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

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

            tableBlock.appendChild(table);
        }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

                    logicalCells[logicalColumn] = cell;

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

                columnIndex += colspan;
            });

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

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

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

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

        function cloneCell(cell) {
            var clone;

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

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

            return clone;
        }

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

        function restoreOriginalRows() {
            removeDisplayedDataRows();

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

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

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

                count += 1;
            }

            return count;
        }

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

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

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

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

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

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

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

                    categoryCell.rowSpan = categoryRowspan;

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

                    row.appendChild(categoryCell);
                }

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

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

                    subcategoryCell.rowSpan =
                        subcategoryRowspan;

                    row.appendChild(subcategoryCell);
                }

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

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

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

                rowParent.appendChild(row);
            });
        }

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

            if (!rawQuery) {
                restoreOriginalRows();

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

                clearButton.hidden = true;
                return;
            }

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

            renderFilteredRows(matches, rawQuery);

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

            clearButton.hidden = false;
        }

        input.addEventListener('input', filterRows);

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

        filterRows();
    });
});

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

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

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

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

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

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

        table.dataset.pluginSearchInitialised = 'true';

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

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

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

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

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

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

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

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

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

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

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

        tableBlock.insertBefore(controls, table);

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

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

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

                if (!parent) {
                    return;
                }

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

                parent.normalize();
            });
        }

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

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

            if (!query) {
                return;
            }

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

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

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

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

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

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

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

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

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

                        lastIndex = offset + match.length;

                        return match;
                    }
                );

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

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

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

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

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

                row.hidden = !matches;

                if (matches) {
                    visibleCount += 1;

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

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

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

        input.addEventListener('input', filterRows);

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

        filterRows();
    });
});

/* -------------------------------------------------------------------------
 * TUFLOW Wiki dark mode toggle
 * ------------------------------------------------------------------------- */

(function () {
    'use strict';

    function initialiseDarkModeToggle() {
        if (document.getElementById('tuflow-dark-mode-toggle')) {
            return;
        }

        var savedMode = localStorage.getItem('tuflowDarkMode');
        var darkModeEnabled = savedMode === 'dark';

        var link = document.createElement('a');
        link.id = 'tuflow-dark-mode-toggle';
        link.href = '#';
        link.textContent = darkModeEnabled
            ? '☀ Light mode'
            : '☾ Dark mode';

        link.setAttribute(
            'aria-label',
            darkModeEnabled
                ? 'Turn off dark mode'
                : 'Turn on dark mode'
        );

        function applyDarkMode(enabled) {
            document.documentElement.classList.toggle(
                'tuflow-dark-mode',
                enabled
            );

            link.textContent = enabled
                ? '☀ Light mode'
                : '☾ Dark mode';

            link.setAttribute(
                'aria-label',
                enabled
                    ? 'Turn off dark mode'
                    : 'Turn on dark mode'
            );
        }

        link.addEventListener('click', function (event) {
            event.preventDefault();

            darkModeEnabled =
                !document.documentElement.classList.contains(
                    'tuflow-dark-mode'
                );

            localStorage.setItem(
                'tuflowDarkMode',
                darkModeEnabled ? 'dark' : 'light'
            );

            applyDarkMode(darkModeEnabled);
        });

        var listItem = document.createElement('li');
        listItem.id = 'pt-tuflow-dark-mode';
        listItem.appendChild(link);

        var personalList =
            document.querySelector('#p-personal ul') ||
            document.querySelector('#p-personal .vector-menu-content-list') ||
            document.querySelector('.vector-user-links ul');

        if (personalList) {
            personalList.appendChild(listItem);
        } else {
            var fallback = document.createElement('div');

            fallback.id = 'tuflow-dark-mode-fallback';
            fallback.appendChild(link);

            document.body.appendChild(fallback);
        }

        applyDarkMode(darkModeEnabled);
    }

    if (document.readyState === 'loading') {
        document.addEventListener(
            'DOMContentLoaded',
            initialiseDarkModeToggle
        );
    } else {
        initialiseDarkModeToggle();
    }
})();