MediaWiki:EveryCRT-SearchEngine.js: Difference between revisions
No edit summary |
No edit summary |
||
| Line 186: | Line 186: | ||
html += '<label>' + filter.label + '</label>'; | html += '<label>' + filter.label + '</label>'; | ||
html += '<div class="crt-multiselect" id="filter-' + filterKey + '" data-filter="' + filterKey + '">'; | 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 += '<div class="crt-multiselect-selected"></div>'; | ||
html += '<input type="text" class="crt-multiselect-input" placeholder="Search ' + filter.label.toLowerCase() + '...">'; | html += '<input type="text" class="crt-multiselect-input" placeholder="Search ' + filter.label.toLowerCase() + '...">'; | ||
html += '</div>'; | |||
html += '<div class="crt-multiselect-dropdown"></div>'; | html += '<div class="crt-multiselect-dropdown"></div>'; | ||
html += '</div>'; | html += '</div>'; | ||
| Line 325: | Line 327: | ||
container.querySelectorAll('.crt-multiselect').forEach(function(ms) { | container.querySelectorAll('.crt-multiselect').forEach(function(ms) { | ||
var input = ms.querySelector('.crt-multiselect-input'); | var input = ms.querySelector('.crt-multiselect-input'); | ||
var filterKey = ms.dataset.filter; | var filterKey = ms.dataset.filter; | ||
input.addEventListener('focus', function() { | input.addEventListener('focus', function() { | ||
ms.classList.add('open'); | |||
updateMultiselectDropdown(ms, filterKey); | updateMultiselectDropdown(ms, filterKey); | ||
}); | }); | ||
input.addEventListener('input', function() { | input.addEventListener('input', function() { | ||
ms.classList.add('open'); | |||
updateMultiselectDropdown(ms, filterKey, this.value); | updateMultiselectDropdown(ms, filterKey, this.value); | ||
}); | }); | ||
| Line 339: | Line 341: | ||
document.addEventListener('click', function(e) { | document.addEventListener('click', function(e) { | ||
if (!ms.contains(e.target)) { | if (!ms.contains(e.target)) { | ||
ms.classList.remove('open'); | |||
} | } | ||
}); | }); | ||
Revision as of 13:54, 25 February 2026
/**
* 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, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
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();
}
};