Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/**
 * EveryCRT Shared Search Engine
 * Faceted search using Semantic MediaWiki API, shared across CRT Search, AV Device Search, etc.
 *
 * Usage from a page-specific JS file:
 *   mw.loader.getScript(
 *       mw.config.get('wgScript') + '?title=MediaWiki:EveryCRT-SearchEngine.js&action=raw&ctype=text/javascript'
 *   ).then(function() {
 *       window.EveryCRTSearch({ containerId: '...', category: '...', ... });
 *   });
 */
window.EveryCRTSearch = function(config) {
    'use strict';

    // ── Constants ──────────────────────────────────────────────────────
    var RESULTS_PER_PAGE = 48;
    var DEBOUNCE_DELAY = 400;
    var BATCH_SIZE = 15;

    // ── Instance state ─────────────────────────────────────────────────
    var container = null;
    var currentOffset = 0;
    var filterOptions = {};
    var totalResults = 0;
    var totalResultsKnown = true;
    var isLoading = false;
    var searchDebounceTimer = null;

    // ── Config defaults ────────────────────────────────────────────────
    var cfg = {
        containerId:              config.containerId              || 'search-app',
        category:                 config.category                 || 'CRT models',
        resultLabel:              config.resultLabel              || 'results',
        noImageEmoji:             config.noImageEmoji             || '\uD83D\uDCFA',
        subobjectParentProperty:  config.subobjectParentProperty  || 'Has CRT model',
        filterConfig:             config.filterConfig             || {},
        sortOptions:              config.sortOptions              || [
            { value: 'brand', label: 'Brand', property: 'Has brand', printoutKey: 'brand' }
        ],
        defaultSort:              config.defaultSort              || 'brand',
        printouts:                config.printouts                || [],
        cardMeta:                 config.cardMeta                 || []
    };

    // ── Utility helpers ────────────────────────────────────────────────

    function escapeHtml(str) {
        if (!str) return '';
        return String(str)
            .replace(/&/g, '&')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;')
            .replace(/'/g, '&#039;');
    }

    function getFirstValue(printout) {
        if (!printout || !printout[0]) return null;
        var val = printout[0];
        return typeof val === 'object' ? (val.fulltext || val.value || null) : val;
    }

    function getImageUrl(printout) {
        if (!printout || !printout[0]) return null;
        var img = printout[0];
        var imgName = typeof img === 'object' ? (img.fulltext || '') : String(img);
        imgName = imgName.replace(/^File:/, '');
        if (!imgName) return null;
        return '/wiki/Special:Redirect/file/' + encodeURIComponent(imgName) + '?width=160';
    }

    function findSortOption(value) {
        for (var i = 0; i < cfg.sortOptions.length; i++) {
            if (cfg.sortOptions[i].value === value) return cfg.sortOptions[i];
        }
        return cfg.sortOptions[0];
    }

    function findFilterConfig(filterKey) {
        for (var sectionKey in cfg.filterConfig) {
            if (cfg.filterConfig[sectionKey].filters[filterKey]) {
                return cfg.filterConfig[sectionKey].filters[filterKey];
            }
        }
        return null;
    }

    function getFilterLabel(filterKey) {
        var fc = findFilterConfig(filterKey);
        return fc ? fc.label : filterKey;
    }

    // ── HTML builders ──────────────────────────────────────────────────

    function buildHTML() {
        var html = '<div class="crt-search-wrapper">';

        // Notification area
        html += '<div class="crt-notifications" id="notifications"></div>';

        // Mobile filter toggle
        html += '<button type="button" class="crt-mobile-filter-toggle" id="mobile-filter-toggle">';
        html += '<span class="crt-filter-icon">\u2630</span> Filters';
        html += '</button>';

        // Filters sidebar
        html += '<div class="crt-search-filters" id="filters-sidebar">';
        html += '<div class="crt-filters-header">';
        html += '<h2>Filters</h2>';
        html += '<div style="display:flex;align-items:center;gap:8px;">';
        html += '<button type="button" class="crt-reset-btn" id="reset-filters">Reset All</button>';
        html += '<button type="button" class="crt-mobile-close-btn" id="mobile-filter-close" aria-label="Close filters">\u2715</button>';
        html += '</div>';
        html += '</div>';

        html += '<div class="crt-filters-body">';
        for (var sectionKey in cfg.filterConfig) {
            html += buildFilterSection(sectionKey, cfg.filterConfig[sectionKey]);
        }
        html += '</div>';

        html += '<div class="crt-apply-filters">';
        html += '<button type="button" class="crt-apply-btn" id="apply-filters">Apply Filters</button>';
        html += '</div>';
        html += '</div>';

        // Results area
        html += '<div class="crt-search-results">';
        html += '<div class="crt-results-header">';
        html += '<div class="crt-results-count" id="results-count">Loading...</div>';
        html += '<div class="crt-results-controls">';
        html += '<div class="crt-results-sort">';
        html += '<label for="sort-by">Sort by:</label>';
        html += '<select id="sort-by">';
        cfg.sortOptions.forEach(function(opt) {
            html += '<option value="' + opt.value + '">' + opt.label + '</option>';
        });
        html += '</select>';
        html += '</div>';
        html += '<div class="crt-view-toggle">';
        html += '<button class="crt-view-btn active" data-view="grid" title="Grid view">\u25A6</button>';
        html += '<button class="crt-view-btn" data-view="list" title="List view">\u2261</button>';
        html += '</div>';
        html += '</div></div>';
        html += '<div class="crt-active-filters" id="active-filters"></div>';
        html += '<div class="crt-results-grid" id="results-grid"></div>';
        html += '<div class="crt-pagination" id="pagination"></div>';
        html += '</div>';

        html += '</div>';
        return html;
    }

    function buildFilterSection(sectionKey, section) {
        var collapsed = section.collapsed ? ' collapsed' : '';
        var html = '<div class="crt-filter-section' + collapsed + '" data-section="' + sectionKey + '">';
        html += '<div class="crt-section-header">';
        html += '<span class="crt-section-title">' + section.label + '</span>';
        html += '<span class="crt-section-toggle">\u25BC</span>';
        html += '</div>';
        html += '<div class="crt-section-content">';
        for (var filterKey in section.filters) {
            html += buildFilterInput(filterKey, section.filters[filterKey]);
        }
        html += '</div></div>';
        return html;
    }

    function buildFilterInput(filterKey, filter) {
        var html = '<div class="crt-filter-item" data-filter="' + filterKey + '">';

        switch (filter.type) {
            case 'select':
                html += '<label for="filter-' + filterKey + '">' + filter.label + '</label>';
                html += '<select id="filter-' + filterKey + '" class="crt-filter-select" data-filter="' + filterKey + '">';
                html += '<option value="">All</option>';
                if (filter.loadFrom === 'static' && filter.values) {
                    filter.values.forEach(function(v) {
                        html += '<option value="' + v + '">' + v + '</option>';
                    });
                }
                html += '</select>';
                break;

            case 'multiselect':
                html += '<label>' + filter.label + '</label>';
                html += '<div class="crt-multiselect" id="filter-' + filterKey + '" data-filter="' + filterKey + '">';
                html += '<div class="crt-multiselect-container">';
                html += '<div class="crt-multiselect-selected"></div>';
                html += '<input type="text" class="crt-multiselect-input" placeholder="Search ' + filter.label.toLowerCase() + '...">';
                html += '</div>';
                html += '<div class="crt-multiselect-dropdown"></div>';
                html += '</div>';
                break;

            case 'number':
                html += '<label for="filter-' + filterKey + '">' + filter.label + '</label>';
                html += '<input type="number" id="filter-' + filterKey + '" class="crt-filter-number" ';
                html += 'data-filter="' + filterKey + '" placeholder="' + (filter.placeholder || '') + '">';
                break;

            case 'checkbox':
                html += '<label class="crt-checkbox-label">';
                html += '<input type="checkbox" id="filter-' + filterKey + '" class="crt-filter-checkbox" ';
                html += 'data-filter="' + filterKey + '">';
                html += '<span>' + filter.label + '</span>';
                html += '</label>';
                break;

            case 'range':
                html += '<label>' + filter.label + '</label>';
                html += '<div class="crt-range-inputs">';
                html += '<input type="number" id="filter-' + filterKey + '-min" class="crt-filter-range-min" ';
                html += 'data-filter="' + filterKey + '-min" placeholder="Min">';
                html += '<span class="crt-range-sep">\u2013</span>';
                html += '<input type="number" id="filter-' + filterKey + '-max" class="crt-filter-range-max" ';
                html += 'data-filter="' + filterKey + '-max" placeholder="Max">';
                html += '</div>';
                break;
        }

        html += '</div>';
        return html;
    }

    // ── Notifications ──────────────────────────────────────────────────

    function showNotification(message, type, duration) {
        var el = document.getElementById('notifications');
        if (!el) return;
        type = type || 'info';
        duration = duration !== undefined ? duration : 8000;

        var n = document.createElement('div');
        n.className = 'crt-notification crt-notification-' + type;
        var icon = type === 'error' ? '\u26A0\uFE0F' : type === 'warning' ? '\u26A0\uFE0F' : '\u2139\uFE0F';
        n.innerHTML = '<span class="crt-notification-icon">' + icon + '</span>' +
            '<span class="crt-notification-message">' + escapeHtml(message) + '</span>' +
            '<button class="crt-notification-close" title="Dismiss">\u00D7</button>';
        n.querySelector('.crt-notification-close').addEventListener('click', function() { n.remove(); });
        el.appendChild(n);
        if (duration > 0) {
            setTimeout(function() { if (n.parentNode) n.remove(); }, duration);
        }
    }

    function clearNotifications() {
        var el = document.getElementById('notifications');
        if (el) el.innerHTML = '';
    }

    // ── Event listeners ────────────────────────────────────────────────

    function attachEventListeners() {
        // Mobile filter toggle
        document.getElementById('mobile-filter-toggle').addEventListener('click', function() {
            document.getElementById('filters-sidebar').classList.toggle('mobile-open');
            this.classList.toggle('active');
        });

        // Mobile filter close
        document.getElementById('mobile-filter-close').addEventListener('click', function() {
            document.getElementById('filters-sidebar').classList.remove('mobile-open');
            document.getElementById('mobile-filter-toggle').classList.remove('active');
        });

        // Section collapse/expand
        container.querySelectorAll('.crt-section-header').forEach(function(header) {
            header.addEventListener('click', function() {
                this.closest('.crt-filter-section').classList.toggle('collapsed');
            });
        });

        // Apply filters (mobile)
        document.getElementById('apply-filters').addEventListener('click', function() {
            currentOffset = 0;
            performSearch();
            updateURL();
            document.getElementById('filters-sidebar').classList.remove('mobile-open');
            document.getElementById('mobile-filter-toggle').classList.remove('active');
        });

        // Reset
        document.getElementById('reset-filters').addEventListener('click', resetFilters);

        // Sort
        document.getElementById('sort-by').addEventListener('change', function() {
            triggerSearch(true);
        });

        // View toggle
        container.querySelectorAll('.crt-view-btn').forEach(function(btn) {
            btn.addEventListener('click', function() {
                container.querySelectorAll('.crt-view-btn').forEach(function(b) { b.classList.remove('active'); });
                this.classList.add('active');
                var grid = document.getElementById('results-grid');
                grid.classList.remove('grid-view', 'list-view');
                grid.classList.add(this.dataset.view + '-view');
            });
        });

        // Select filters — immediate
        container.querySelectorAll('.crt-filter-select').forEach(function(select) {
            select.addEventListener('change', function() { triggerSearch(true); });
        });

        // Number filters — debounced
        container.querySelectorAll('.crt-filter-number').forEach(function(input) {
            input.addEventListener('input', function() { triggerSearch(false); });
            input.addEventListener('keypress', function(e) {
                if (e.key === 'Enter') triggerSearch(true);
            });
        });

        // Checkbox filters — immediate
        container.querySelectorAll('.crt-filter-checkbox').forEach(function(cb) {
            cb.addEventListener('change', function() { triggerSearch(true); });
        });

        // Multiselects
        initMultiselects();
    }

    // ── Multiselect logic ──────────────────────────────────────────────

    function initMultiselects() {
        container.querySelectorAll('.crt-multiselect').forEach(function(ms) {
            var input = ms.querySelector('.crt-multiselect-input');
            var filterKey = ms.dataset.filter;

            input.addEventListener('focus', function() {
                ms.classList.add('open');
                updateMultiselectDropdown(ms, filterKey);
            });

            input.addEventListener('input', function() {
                ms.classList.add('open');
                updateMultiselectDropdown(ms, filterKey, this.value);
            });

            document.addEventListener('click', function(e) {
                if (!ms.contains(e.target)) {
                    ms.classList.remove('open');
                }
            });
        });
    }

    function updateMultiselectDropdown(ms, filterKey, searchText) {
        var dropdown = ms.querySelector('.crt-multiselect-dropdown');
        var options = filterOptions[filterKey] || [];
        var selected = getMultiselectValues(ms);
        searchText = (searchText || '').toLowerCase();

        var html = '';
        var count = 0;
        options.forEach(function(opt) {
            var optValue = typeof opt === 'object' ? opt.value : opt;
            var optLabel = typeof opt === 'object' ? opt.label : opt;
            if (searchText && optLabel.toLowerCase().indexOf(searchText) === -1) return;

            var isSelected = selected.indexOf(optValue) !== -1;
            html += '<div class="crt-multiselect-option' + (isSelected ? ' selected' : '') + '" data-value="' + escapeHtml(optValue) + '">';
            html += '<span class="crt-option-check">' + (isSelected ? '\u2713' : '') + '</span>';
            html += '<span class="crt-option-label">' + escapeHtml(optLabel) + '</span>';
            html += '</div>';
            count++;
        });

        if (count === 0) {
            html = '<div class="crt-multiselect-empty">No options found</div>';
        }

        dropdown.innerHTML = html;

        dropdown.querySelectorAll('.crt-multiselect-option').forEach(function(opt) {
            opt.addEventListener('click', function(e) {
                e.stopPropagation();
                toggleMultiselectOption(ms, this.dataset.value);
            });
        });
    }

    function toggleMultiselectOption(ms, value) {
        var selectedContainer = ms.querySelector('.crt-multiselect-selected');
        var existing = selectedContainer.querySelector('[data-value="' + CSS.escape(value) + '"]');

        if (existing) {
            existing.remove();
        } else {
            var tag = document.createElement('span');
            tag.className = 'crt-multiselect-tag';
            tag.dataset.value = value;
            tag.innerHTML = escapeHtml(value) + ' <span class="crt-tag-remove">\u00D7</span>';
            tag.querySelector('.crt-tag-remove').addEventListener('click', function(e) {
                e.stopPropagation();
                tag.remove();
                updateMultiselectDropdown(ms, ms.dataset.filter, ms.querySelector('.crt-multiselect-input').value);
                triggerSearch(true);
            });
            selectedContainer.appendChild(tag);
        }

        updateMultiselectDropdown(ms, ms.dataset.filter, ms.querySelector('.crt-multiselect-input').value);
        triggerSearch(true);
    }

    function getMultiselectValues(ms) {
        var values = [];
        ms.querySelectorAll('.crt-multiselect-tag').forEach(function(tag) {
            values.push(tag.dataset.value);
        });
        return values;
    }

    // ── Filter data loading ────────────────────────────────────────────

    function loadFilterOptions() {
        var promises = [];

        for (var sectionKey in cfg.filterConfig) {
            var section = cfg.filterConfig[sectionKey];
            for (var filterKey in section.filters) {
                var filter = section.filters[filterKey];
                if (filter.loadFrom === 'category') {
                    promises.push(loadCategoryOptions(filterKey, filter.category, filter.categoryMemberTypes));
                } else if (filter.loadFrom === 'smw') {
                    promises.push(loadSMWOptions(filterKey, filter.smwQuery, filter.smwProperty));
                } else if (filter.loadFrom === 'static' && filter.values) {
                    filterOptions[filterKey] = filter.values.map(function(v) {
                        return { value: v, label: v };
                    });
                }
            }
        }

        Promise.all(promises).then(function() {
            populateSelectFilters();
            parseURLParams();
            performSearch();
        });
    }

    function loadCategoryOptions(filterKey, category, memberTypes) {
        return new Promise(function(resolve) {
            var api = new mw.Api();
            var params = {
                action: 'query',
                list: 'categorymembers',
                cmtitle: 'Category:' + category,
                cmlimit: 500,
                format: 'json'
            };
            if (memberTypes) params.cmtype = memberTypes;
            api.get(params).done(function(data) {
                filterOptions[filterKey] = [];
                if (data.query && data.query.categorymembers) {
                    data.query.categorymembers.forEach(function(member) {
                        var label = member.ns === 0 ? member.title : member.title.replace(/^[^:]+:/, '');
                        filterOptions[filterKey].push({ value: label, label: label });
                    });
                    filterOptions[filterKey].sort(function(a, b) { return a.label.localeCompare(b.label); });
                }
                resolve();
            }).fail(function() {
                filterOptions[filterKey] = [];
                resolve();
            });
        });
    }

    function loadSMWOptions(filterKey, smwQuery, smwProperty) {
        return new Promise(function(resolve) {
            var api = new mw.Api();
            api.get({
                action: 'ask',
                query: smwQuery + '|?' + smwProperty + '|limit=500|format=json',
                format: 'json'
            }).done(function(data) {
                var seen = {};
                filterOptions[filterKey] = [];
                if (data.query && data.query.results) {
                    Object.values(data.query.results).forEach(function(result) {
                        if (result.printouts && result.printouts[smwProperty]) {
                            result.printouts[smwProperty].forEach(function(val) {
                                var v = typeof val === 'object' ? (val.fulltext || val.value || '') : String(val);
                                if (v && !seen[v]) {
                                    seen[v] = true;
                                    filterOptions[filterKey].push({ value: v, label: v });
                                }
                            });
                        }
                    });
                    filterOptions[filterKey].sort(function(a, b) { return a.label.localeCompare(b.label); });
                }
                resolve();
            }).fail(function() {
                filterOptions[filterKey] = [];
                resolve();
            });
        });
    }

    function populateSelectFilters() {
        for (var sectionKey in cfg.filterConfig) {
            var section = cfg.filterConfig[sectionKey];
            for (var filterKey in section.filters) {
                var filter = section.filters[filterKey];
                if (filter.type === 'select' && (filter.loadFrom === 'category' || filter.loadFrom === 'smw')) {
                    var select = document.getElementById('filter-' + filterKey);
                    if (select && filterOptions[filterKey]) {
                        var currentValue = select.value;
                        select.innerHTML = '<option value="">All</option>';
                        filterOptions[filterKey].forEach(function(opt) {
                            var option = document.createElement('option');
                            option.value = opt.value;
                            option.textContent = opt.label;
                            select.appendChild(option);
                        });
                        select.value = currentValue;
                    }
                }
            }
        }
    }

    // ── Filter collection ──────────────────────────────────────────────

    function collectFilters() {
        var filters = {};

        container.querySelectorAll('.crt-filter-select, .crt-filter-number').forEach(function(input) {
            var value = input.value.trim();
            if (value) filters[input.dataset.filter] = value;
        });

        container.querySelectorAll('.crt-filter-checkbox').forEach(function(input) {
            if (input.checked) filters[input.dataset.filter] = true;
        });

        container.querySelectorAll('.crt-multiselect').forEach(function(ms) {
            var values = getMultiselectValues(ms);
            if (values.length > 0) filters[ms.dataset.filter] = values;
        });

        return filters;
    }

    function resetFilters() {
        clearNotifications();
        container.querySelectorAll('.crt-filter-select, .crt-filter-number').forEach(function(input) { input.value = ''; });
        container.querySelectorAll('.crt-filter-checkbox').forEach(function(input) { input.checked = false; });
        container.querySelectorAll('.crt-multiselect-selected').forEach(function(el) { el.innerHTML = ''; });
        currentOffset = 0;

        var params = new URLSearchParams(window.location.search);
        var titleParam = params.get('title');
        var newUrl = window.location.pathname;
        if (titleParam) newUrl += '?title=' + encodeURIComponent(titleParam);
        history.replaceState(null, '', newUrl);

        performSearch();
    }

    // ── Active filters display ─────────────────────────────────────────

    function updateActiveFilters() {
        var filters = collectFilters();
        var el = document.getElementById('active-filters');
        var html = '';
        var hasFilters = false;

        for (var key in filters) {
            hasFilters = true;
            var value = filters[key];
            var label = getFilterLabel(key);

            if (Array.isArray(value)) {
                value.forEach(function(v) {
                    html += '<span class="crt-active-filter" data-filter="' + key + '" data-value="' + escapeHtml(v) + '">';
                    html += '<span class="crt-af-label">' + label + ':</span> ';
                    html += '<span class="crt-af-value">' + escapeHtml(v) + '</span>';
                    html += ' <span class="crt-af-remove">\u00D7</span></span>';
                });
            } else if (value === true) {
                html += '<span class="crt-active-filter" data-filter="' + key + '">';
                html += '<span class="crt-af-value">' + label + '</span>';
                html += ' <span class="crt-af-remove">\u00D7</span></span>';
            } else {
                html += '<span class="crt-active-filter" data-filter="' + key + '">';
                html += '<span class="crt-af-label">' + label + ':</span> ';
                html += '<span class="crt-af-value">' + escapeHtml(value) + '</span>';
                html += ' <span class="crt-af-remove">\u00D7</span></span>';
            }
        }

        if (hasFilters) {
            html += '<button class="crt-clear-all-filters">Clear All</button>';
        }

        el.innerHTML = html;

        el.querySelectorAll('.crt-active-filter').forEach(function(af) {
            af.querySelector('.crt-af-remove').addEventListener('click', function() {
                removeActiveFilter(af.dataset.filter, af.dataset.value);
            });
        });

        var clearAll = el.querySelector('.crt-clear-all-filters');
        if (clearAll) clearAll.addEventListener('click', resetFilters);
    }

    function removeActiveFilter(filterKey, value) {
        var fc = findFilterConfig(filterKey);
        if (!fc) return;

        switch (fc.type) {
            case 'select':
            case 'number':
                document.getElementById('filter-' + filterKey).value = '';
                break;
            case 'checkbox':
                document.getElementById('filter-' + filterKey).checked = false;
                break;
            case 'multiselect':
                var ms = document.getElementById('filter-' + filterKey);
                var tag = ms.querySelector('.crt-multiselect-tag[data-value="' + CSS.escape(value) + '"]');
                if (tag) tag.remove();
                break;
        }

        triggerSearch(true);
    }

    // ── Search trigger ─────────────────────────────────────────────────

    function triggerSearch(immediate) {
        if (searchDebounceTimer) clearTimeout(searchDebounceTimer);

        if (immediate) {
            currentOffset = 0;
            performSearch();
            updateURL();
        } else {
            searchDebounceTimer = setTimeout(function() {
                currentOffset = 0;
                performSearch();
                updateURL();
            }, DEBOUNCE_DELAY);
        }
    }

    // ── SMW query builder ──────────────────────────────────────────────

    function buildSMWQuery() {
        var filters = collectFilters();
        var conditions = ['[[Category:' + cfg.category + ']]'];
        var subobjectConditions = [];

        for (var sectionKey in cfg.filterConfig) {
            var section = cfg.filterConfig[sectionKey];
            for (var filterKey in section.filters) {
                var filter = section.filters[filterKey];
                var value = filters[filterKey];
                if (value === undefined || value === '') continue;

                if (filter.subobjectQuery) {
                    if (filter.type === 'checkbox' && value === true) {
                        subobjectConditions.push({
                            property: filter.subobjectProperty,
                            value: filter.subobjectValue,
                            extraCondition: filter.subobjectExtraCondition || ''
                        });
                    } else if (filter.type === 'multiselect' && Array.isArray(value) && value.length > 0) {
                        value.forEach(function(v) {
                            subobjectConditions.push({
                                property: filter.subobjectProperty,
                                value: v,
                                extraCondition: filter.subobjectExtraCondition || ''
                            });
                        });
                    }
                } else if (filter.type === 'select' && filter.property) {
                    conditions.push('[[' + filter.property + '::' + value + ']]');
                } else if (filter.type === 'number' && filter.property) {
                    var comp = filter.comparison || '=';
                    if (comp === '>=') {
                        conditions.push('[[' + filter.property + '::\u2265' + value + ']]');
                    } else if (comp === '<=') {
                        conditions.push('[[' + filter.property + '::\u2264' + value + ']]');
                    } else {
                        conditions.push('[[' + filter.property + '::' + value + ']]');
                    }
                } else if (filter.type === 'checkbox' && filter.property && value === true) {
                    if (filter.containsValue) {
                        conditions.push('[[' + filter.property + '::~*' + filter.containsValue + '*]]');
                    }
                } else if (filter.type === 'multiselect' && filter.property && Array.isArray(value) && value.length > 0) {
                    var orParts = value.map(function(v) { return filter.property + '::' + v; });
                    conditions.push('[[' + orParts.join('||') + ']]');
                }
            }
        }

        return {
            mainQuery: conditions.join(''),
            subobjectConditions: subobjectConditions
        };
    }

    /** Build the |?Property=key printout string */
    function buildPrintoutString() {
        var parts = ['|?Has image#100px=image', '|?Has brand=brand', '|?Has model code=model'];
        cfg.printouts.forEach(function(p) {
            parts.push('|?' + p.property + (p.format || '') + '=' + p.key);
        });
        return parts.join('');
    }

    // ── Search execution ───────────────────────────────────────────────

    function performSearch() {
        if (isLoading) return;
        isLoading = true;
        showLoading();
        updateActiveFilters();

        var queryData = buildSMWQuery();
        var query = queryData.mainQuery;
        var sortOpt = findSortOption(document.getElementById('sort-by').value);
        var sortProperty = sortOpt.property;

        // Subobject path (inputs / mods)
        if (queryData.subobjectConditions.length > 0) {
            performSubobjectSearch(queryData, sortProperty, sortOpt);
            return;
        }

        var api = new mw.Api();
        var printouts = buildPrintoutString();

        // Count query
        api.get({
            action: 'ask',
            query: query + '|limit=0|format=json',
            format: 'json'
        }).done(function(countData) {
            totalResults = 0;
            totalResultsKnown = true;
            if (countData.query && countData.query.meta && countData.query.meta.count !== undefined) {
                totalResults = parseInt(countData.query.meta.count, 10) || 0;
            }

            api.get({
                action: 'ask',
                query: query + printouts +
                    '|sort=' + sortProperty +
                    '|order=asc' +
                    '|limit=' + RESULTS_PER_PAGE +
                    '|offset=' + currentOffset +
                    '|format=json',
                format: 'json'
            }).done(function(data) {
                isLoading = false;
                displayResults(data);
            }).fail(function(error) {
                isLoading = false;
                showError('Search failed. Please try again.');
                console.error('SMW query error:', error);
            });
        }).fail(function(error) {
            console.warn('Count query failed, proceeding without count:', error);
            totalResults = 0;
            totalResultsKnown = false;

            api.get({
                action: 'ask',
                query: query + printouts +
                    '|sort=' + sortProperty +
                    '|order=asc' +
                    '|limit=' + RESULTS_PER_PAGE +
                    '|offset=' + currentOffset +
                    '|format=json',
                format: 'json'
            }).done(function(data) {
                isLoading = false;
                displayResults(data);
            }).fail(function(error) {
                isLoading = false;
                showError('Search failed. Please try again.');
                console.error('SMW query error:', error);
            });
        });
    }

    // ── Subobject search (inputs / mods) ───────────────────────────────

    function performSubobjectSearch(queryData, sortProperty, sortOpt) {
        var api = new mw.Api();
        var subConditions = queryData.subobjectConditions;
        var parentProperty = cfg.subobjectParentProperty;

        var subQueries = subConditions.map(function(cond) {
            var sq = '[[' + cond.property + '::' + cond.value + ']]';
            if (cond.extraCondition) sq += cond.extraCondition;
            sq += '|?' + parentProperty + '|limit=5000|format=json';
            return sq;
        });

        var promises = subQueries.map(function(sq) {
            return api.get({ action: 'ask', query: sq, format: 'json' });
        });

        Promise.all(promises).then(function(results) {
            var matchingModels = new Set();
            var isFirst = true;

            results.forEach(function(data) {
                if (data.error) return;
                var currentMatches = new Set();
                if (data.query && data.query.results) {
                    Object.values(data.query.results).forEach(function(result) {
                        if (result.printouts && result.printouts[parentProperty]) {
                            result.printouts[parentProperty].forEach(function(model) {
                                currentMatches.add(typeof model === 'object' ? model.fulltext : model);
                            });
                        }
                    });
                }
                if (isFirst) {
                    matchingModels = currentMatches;
                    isFirst = false;
                } else {
                    matchingModels = new Set([...matchingModels].filter(function(x) { return currentMatches.has(x); }));
                }
            });

            if (matchingModels.size === 0) {
                isLoading = false;
                totalResults = 0;
                totalResultsKnown = true;
                displayResults({ query: { results: {} } });
                return;
            }

            var arr = Array.from(matchingModels).sort();
            totalResults = arr.length;
            totalResultsKnown = true;

            var mainConditions = queryData.mainQuery.replace('[[Category:' + cfg.category + ']]', '').trim();
            fetchModelDetailsBatched(arr, mainConditions, sortProperty, sortOpt);
        }).catch(function(error) {
            isLoading = false;
            showError('Search failed. Please try again.');
            console.error('Subobject query error:', error);
        });
    }

    function fetchModelDetailsBatched(allModels, mainConditions, sortProperty, sortOpt) {
        var api = new mw.Api();
        var pageModels = allModels.slice(currentOffset, currentOffset + RESULTS_PER_PAGE);

        if (pageModels.length === 0) {
            isLoading = false;
            displayResults({ query: { results: {} } });
            return;
        }

        var batches = [];
        for (var i = 0; i < pageModels.length; i += BATCH_SIZE) {
            batches.push(pageModels.slice(i, i + BATCH_SIZE));
        }

        var printouts = buildPrintoutString();

        var batchPromises = batches.map(function(batch) {
            var disjunction = batch.join('||');
            var query = '[[' + disjunction + ']]';
            if (mainConditions) {
                query = '[[Category:' + cfg.category + ']]' + mainConditions + query;
            }
            return api.get({
                action: 'ask',
                query: query + printouts + '|limit=50|format=json',
                format: 'json'
            });
        });

        Promise.all(batchPromises).then(function(batchResults) {
            var mergedResults = {};
            var hasErrors = false;

            batchResults.forEach(function(data) {
                if (data.error) { hasErrors = true; return; }
                if (data.query && data.query.results) {
                    Object.assign(mergedResults, data.query.results);
                }
            });

            if (hasErrors && Object.keys(mergedResults).length === 0) {
                isLoading = false;
                showError('Search query too complex. Try fewer filters.');
                return;
            }

            // Client-side sort
            var printoutKey = sortOpt.printoutKey || 'brand';
            var sortedKeys = Object.keys(mergedResults).sort(function(a, b) {
                var aVal = getSortValue(mergedResults[a], printoutKey);
                var bVal = getSortValue(mergedResults[b], printoutKey);
                return aVal.localeCompare(bVal);
            });

            var sortedResults = {};
            sortedKeys.forEach(function(key) { sortedResults[key] = mergedResults[key]; });

            isLoading = false;
            displayResults({ query: { results: sortedResults } });
        }).catch(function(error) {
            isLoading = false;
            showError('Search failed. Please try again.');
            console.error('Batch query error:', error);
        });
    }

    function getSortValue(result, printoutKey) {
        if (!result || !result.printouts) return '';
        var printout = result.printouts[printoutKey];
        if (!printout || !printout[0]) return '';
        var val = printout[0];
        return typeof val === 'object' ? String(val.fulltext || val.value || '') : String(val);
    }

    // ── Result display ─────────────────────────────────────────────────

    function displayResults(data) {
        var grid = document.getElementById('results-grid');
        var countDiv = document.getElementById('results-count');

        if (!data.query || !data.query.results || Object.keys(data.query.results).length === 0) {
            grid.innerHTML = '<div class="crt-no-results">' +
                '<div class="crt-no-results-icon">' + cfg.noImageEmoji + '</div>' +
                '<div class="crt-no-results-text">No ' + cfg.resultLabel + ' found matching your filters.</div>' +
                '<div class="crt-no-results-hint">Try adjusting your filters or <a href="#" id="reset-from-no-results">reset all filters</a>.</div>' +
                '</div>';
            countDiv.textContent = '0 results';
            document.getElementById('pagination').innerHTML = '';

            var resetLink = document.getElementById('reset-from-no-results');
            if (resetLink) resetLink.addEventListener('click', function(e) { e.preventDefault(); resetFilters(); });
            return;
        }

        var results = data.query.results;
        var resultKeys = Object.keys(results);

        if (!totalResultsKnown || totalResults === 0) {
            if (resultKeys.length === RESULTS_PER_PAGE) {
                totalResults = currentOffset + resultKeys.length + 1;
                totalResultsKnown = false;
            } else {
                totalResults = currentOffset + resultKeys.length;
                totalResultsKnown = true;
            }
        }

        var startNum = currentOffset + 1;
        var endNum = currentOffset + resultKeys.length;
        if (!totalResultsKnown) {
            countDiv.textContent = 'Showing ' + startNum + '\u2013' + endNum + ' of ' + totalResults + '+ ' + cfg.resultLabel;
        } else {
            endNum = Math.min(endNum, totalResults);
            countDiv.textContent = 'Showing ' + startNum + '\u2013' + endNum + ' of ' + totalResults + ' ' + cfg.resultLabel;
        }

        var html = '';
        resultKeys.forEach(function(key) {
            var item = results[key];
            var po = item.printouts || {};
            var title = key.replace(/_/g, ' ');
            var url = item.fullurl || mw.util.getUrl(key);
            var brand = getFirstValue(po.brand);
            var model = getFirstValue(po.model) || title;
            var image = getImageUrl(po.image);

            html += '<a href="' + url + '" class="crt-result-card">';
            html += '<div class="crt-card-image">';
            if (image) {
                html += '<img src="' + image + '" alt="' + escapeHtml(title) + '" loading="lazy">';
            } else {
                html += '<div class="crt-no-image"><span>' + cfg.noImageEmoji + '</span></div>';
            }
            html += '</div>';
            html += '<div class="crt-card-info">';
            html += '<div class="crt-card-brand">' + escapeHtml(brand || '') + '</div>';
            html += '<div class="crt-card-title">' + escapeHtml(model) + '</div>';
            html += '<div class="crt-card-meta">';

            cfg.cardMeta.forEach(function(meta) {
                var val = getFirstValue(po[meta.key]);
                if (val) {
                    var cls = meta.cssClass ? ' class="' + meta.cssClass + '"' : '';
                    var suffix = meta.suffix || '';
                    html += '<span' + cls + '>' + escapeHtml(val) + suffix + '</span>';
                }
            });

            html += '</div></div></a>';
        });

        grid.innerHTML = html;
        grid.classList.add('grid-view');
        buildPagination(resultKeys.length);
    }

    // ── Pagination ─────────────────────────────────────────────────────

    function buildPagination(currentPageResultCount) {
        var pagination = document.getElementById('pagination');

        if (currentOffset === 0 && currentPageResultCount < RESULTS_PER_PAGE && totalResultsKnown) {
            pagination.innerHTML = '';
            return;
        }

        var currentPage = Math.floor(currentOffset / RESULTS_PER_PAGE) + 1;
        var html = '<div class="crt-pagination-info">';

        if (totalResultsKnown && totalResults > 0) {
            var totalPages = Math.ceil(totalResults / RESULTS_PER_PAGE);
            html += 'Page ' + currentPage + ' of ' + totalPages;
        } else {
            html += 'Page ' + currentPage;
        }

        html += '</div><div class="crt-pagination-buttons">';

        if (currentPage > 1) {
            html += '<button class="crt-page-btn crt-page-prev" data-offset="' + ((currentPage - 2) * RESULTS_PER_PAGE) + '">\u2190 Previous</button>';
        }

        if (totalResultsKnown && totalResults > 0) {
            var totalPages = Math.ceil(totalResults / RESULTS_PER_PAGE);
            var startPage = Math.max(1, currentPage - 2);
            var endPage = Math.min(totalPages, currentPage + 2);

            if (startPage > 1) {
                html += '<button class="crt-page-btn" data-offset="0">1</button>';
                if (startPage > 2) html += '<span class="crt-page-ellipsis">\u2026</span>';
            }
            for (var i = startPage; i <= endPage; i++) {
                var active = i === currentPage ? ' active' : '';
                html += '<button class="crt-page-btn' + active + '" data-offset="' + ((i - 1) * RESULTS_PER_PAGE) + '">' + i + '</button>';
            }
            if (endPage < totalPages) {
                if (endPage < totalPages - 1) html += '<span class="crt-page-ellipsis">\u2026</span>';
                html += '<button class="crt-page-btn" data-offset="' + ((totalPages - 1) * RESULTS_PER_PAGE) + '">' + totalPages + '</button>';
            }
        }

        if (currentPageResultCount === RESULTS_PER_PAGE) {
            html += '<button class="crt-page-btn crt-page-next" data-offset="' + (currentPage * RESULTS_PER_PAGE) + '">Next \u2192</button>';
        }

        html += '</div>';
        pagination.innerHTML = html;

        pagination.querySelectorAll('.crt-page-btn').forEach(function(btn) {
            btn.addEventListener('click', function() {
                currentOffset = parseInt(this.dataset.offset, 10);
                performSearch();
                updateURL();
                container.querySelector('.crt-results-header').scrollIntoView({ behavior: 'smooth' });
            });
        });
    }

    // ── URL management ─────────────────────────────────────────────────

    function updateURL() {
        var filters = collectFilters();
        var params = new URLSearchParams(window.location.search);
        var titleParam = params.get('title');

        params = new URLSearchParams();
        if (titleParam) params.set('title', titleParam);

        for (var key in filters) {
            var value = filters[key];
            if (Array.isArray(value)) {
                params.set(key, value.join(','));
            } else if (value === true) {
                params.set(key, '1');
            } else {
                params.set(key, value);
            }
        }

        var sortBy = document.getElementById('sort-by').value;
        if (sortBy !== cfg.defaultSort) params.set('sort', sortBy);
        if (currentOffset > 0) params.set('offset', currentOffset);

        var newUrl = window.location.pathname;
        var paramString = params.toString();
        if (paramString) newUrl += '?' + paramString;
        history.replaceState(null, '', newUrl);
    }

    function parseURLParams() {
        var params = new URLSearchParams(window.location.search);
        var invalidFilters = [];
        var hasFilterParams = false;

        params.forEach(function(value, key) {
            if (key !== 'title') hasFilterParams = true;
        });
        if (!hasFilterParams) return;

        params.forEach(function(value, key) {
            if (key === 'title') return;
            if (key === 'sort') {
                document.getElementById('sort-by').value = value;
                return;
            }
            if (key === 'offset') {
                currentOffset = parseInt(value, 10) || 0;
                return;
            }

            var filterConfig = findFilterConfig(key);
            if (!filterConfig) {
                invalidFilters.push({ key: key, value: value, reason: 'Unknown filter "' + key + '"' });
                return;
            }

            switch (filterConfig.type) {
                case 'select':
                    var select = document.getElementById('filter-' + key);
                    if (select) {
                        var optionExists = Array.from(select.options).some(function(opt) { return opt.value === value; });
                        if (optionExists) {
                            select.value = value;
                        } else if ((filterConfig.loadFrom === 'category' || filterConfig.loadFrom === 'smw') && filterOptions[key]) {
                            var dynamicExists = filterOptions[key].some(function(opt) { return opt.value === value; });
                            if (dynamicExists) {
                                var tempOption = document.createElement('option');
                                tempOption.value = value;
                                tempOption.textContent = value;
                                select.appendChild(tempOption);
                                select.value = value;
                            } else {
                                invalidFilters.push({ key: key, value: value, reason: '"' + value + '" is not a valid option for ' + filterConfig.label });
                            }
                        } else {
                            invalidFilters.push({ key: key, value: value, reason: '"' + value + '" is not a valid option for ' + filterConfig.label });
                        }
                    }
                    break;
                case 'number':
                    var numInput = document.getElementById('filter-' + key);
                    if (numInput) numInput.value = value;
                    break;
                case 'checkbox':
                    var checkbox = document.getElementById('filter-' + key);
                    if (checkbox) checkbox.checked = (value === '1' || value === 'true');
                    break;
                case 'multiselect':
                    var ms = document.getElementById('filter-' + key);
                    if (ms) {
                        var values = value.split(',');
                        var selectedContainer = ms.querySelector('.crt-multiselect-selected');
                        values.forEach(function(v) {
                            v = v.trim();
                            if (!v) return;
                            var existingTag = selectedContainer.querySelector('[data-value="' + CSS.escape(v) + '"]');
                            if (existingTag) return;
                            var tag = document.createElement('span');
                            tag.className = 'crt-multiselect-tag';
                            tag.dataset.value = v;
                            tag.innerHTML = escapeHtml(v) + ' <span class="crt-tag-remove">\u00D7</span>';
                            tag.querySelector('.crt-tag-remove').addEventListener('click', function(e) {
                                e.stopPropagation();
                                tag.remove();
                                triggerSearch(true);
                            });
                            selectedContainer.appendChild(tag);
                        });
                    }
                    break;
            }
        });

        if (invalidFilters.length > 0) {
            invalidFilters.forEach(function(invalid) {
                showNotification(invalid.reason + '. This filter was ignored.', 'warning', 10000);
            });
        }
    }

    // ── UI states ──────────────────────────────────────────────────────

    function showLoading() {
        document.getElementById('results-grid').innerHTML =
            '<div class="crt-loading"><div class="crt-spinner"></div><div class="crt-loading-text">Searching...</div></div>';
    }

    function showError(message) {
        document.getElementById('results-grid').innerHTML =
            '<div class="crt-error"><div class="crt-error-icon">\u26A0\uFE0F</div><div class="crt-error-text">' + escapeHtml(message) + '</div></div>';
        document.getElementById('pagination').innerHTML = '';
    }

    // ── Bootstrap ──────────────────────────────────────────────────────

    function boot() {
        container = document.getElementById(cfg.containerId);
        if (!container) {
            console.error('EveryCRTSearch: container #' + cfg.containerId + ' not found');
            return;
        }
        container.innerHTML = buildHTML();
        attachEventListeners();
        loadFilterOptions();
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', boot);
    } else {
        boot();
    }
};
MediaWiki Appliance - Powered by TurnKey Linux