MediaWiki:CategorySearch.js
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.
$(function() {
const $container = $('#category-search-app');
if (!$container.length) return;
// Inject clean UI for the interactive tree explorer AND the settings panel
$container.html(`
<div class="flexlayout" style="gap: 20px; align-items: flex-start;">
<!-- Left: The Search App -->
<div style="flex: 2; width: 100%;">
<div class="mp-category-explorer" style="margin-bottom: 0;">
<div class="mp-search-input-container">
<input type="text" id="mp-cat-search-input" placeholder="Search for categories (e.g., 'Bahá', 'House of Worship')..." autocomplete="off">
</div>
<div class="mp-explorer-container">
<ul id="mp-tree-root" class="mp-explorer-tree">
<li class="mp-empty-state">Loading...</li>
</ul>
</div>
</div>
</div>
<!-- Right: Search Options -->
<div style="flex: 1; padding: 20px; background: var(--background-color-ui-subtle); border: 1px solid var(--border-color-base); border-radius: 4px;">
<div style="font-weight: bold; margin-bottom: 12px; font-size: 1.1em; color: var(--color-text-main);">Search Mode</div>
<label style="display: block; margin-bottom: 8px; cursor: pointer; color: var(--color-text-main);">
<input type="radio" name="catSearchMode" value="exact" checked>
<strong>Exact & Alphabetical</strong>
</label>
<div style="font-size: 0.9em; color: var(--color-base); margin-bottom: 20px; margin-left: 24px; line-height: 1.4;">
Fastest. Matches categories starting exactly with your text (e.g., "Bahai" finds "Bahai News"). Best if you know the naming structure.
</div>
<label style="display: block; margin-bottom: 8px; cursor: pointer; color: var(--color-text-main);">
<input type="radio" name="catSearchMode" value="fuzzy">
<strong>Keyword Match</strong>
</label>
<div style="font-size: 0.9em; color: var(--color-base); margin-left: 24px; line-height: 1.4;">
Forgiving. Finds words anywhere in the title (e.g., "Brazil Children" finds "Children of Brazil"). Best for general browsing.
</div>
</div>
</div>
`);
const api = new mw.Api();
const searchInput = document.getElementById('mp-cat-search-input');
const treeRoot = document.getElementById('mp-tree-root');
let debounceTimer;
// Listen for typing
searchInput.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
const query = e.target.value.trim();
debounceTimer = setTimeout(() => performSearch(query), 300);
});
// Listen for the user changing the radio button
const radios = document.querySelectorAll('input[name="catSearchMode"]');
radios.forEach(radio => {
radio.addEventListener('change', () => {
const query = searchInput.value.trim();
performSearch(query);
});
});
async function performSearch(query) {
treeRoot.classList.add('mp-is-loading');
// Find out which mode the user selected
const selectedRadio = document.querySelector('input[name="catSearchMode"]:checked');
const mode = selectedRadio ? selectedRadio.value : 'exact';
// If the box is empty, we MUST use exact mode with 'A' (you can't full-text search for nothing)
const isDefault = query.length === 0;
const actualQuery = isDefault ? 'A' : query;
const actualMode = isDefault ? 'exact' : mode;
try {
let categoriesData = [];
if (actualMode === 'exact') {
// 1. Alphabetical Prefix Search
const res = await api.get({
action: 'query',
list: 'allcategories',
acprefix: actualQuery,
aclimit: 15,
acprop: 'size',
format: 'json'
});
const cats = res.query.allcategories || [];
categoriesData = cats.map(c => ({
title: c['*'],
hasChildren: c.subcats > 0
}));
} else {
// 2. Keyword Match (Namespaces 14 only)
// Force the search engine to only look at titles by prefixing each word with "intitle:"
// e.g., "Brazil Children" becomes "intitle:Brazil intitle:Children"
const titleQuery = actualQuery.split(/\s+/).map(word => 'intitle:' + word).join(' ');
const res = await api.get({
action: 'query',
generator: 'search',
gsrsearch: titleQuery,
gsrnamespace: 14,
gsrlimit: 15,
prop: 'categoryinfo',
format: 'json'
});
// Safely check if res.query exists to prevent errors on 0 results
const pages = (res.query && res.query.pages) ? Object.values(res.query.pages) : [];
// Sort by the search engine's relevance index
pages.sort((a, b) => a.index - b.index);
categoriesData = pages.map(p => ({
title: p.title.replace(/^Category:/, ''),
hasChildren: p.categoryinfo && p.categoryinfo.subcats > 0
}));
}
// Build the UI
treeRoot.innerHTML = '';
if (categoriesData.length === 0) {
treeRoot.innerHTML = '<li class="mp-empty-state">No matches found.</li>';
} else {
categoriesData.forEach(cat => {
treeRoot.appendChild(createTreeNode(cat.title, cat.hasChildren));
});
}
} catch (err) {
console.error('Category search error:', err);
treeRoot.innerHTML = '<li class="mp-tree-msg error">Error searching.</li>';
} finally {
treeRoot.classList.remove('mp-is-loading');
}
}
function createTreeNode(catName, hasChildren) {
const li = document.createElement('li');
li.className = 'mp-tree-item';
const row = document.createElement('div');
row.className = 'mp-tree-row';
const toggle = document.createElement('span');
toggle.className = 'mp-tree-toggle' + (hasChildren ? '' : ' empty');
toggle.textContent = '▶';
const link = document.createElement('a');
link.className = 'mp-tree-link';
link.textContent = catName;
link.href = mw.util.getUrl('Category:' + catName);
row.appendChild(toggle);
row.appendChild(link);
li.appendChild(row);
const childContainer = document.createElement('ul');
childContainer.className = 'mp-tree-children';
li.appendChild(childContainer);
if (hasChildren) {
let loaded = false;
toggle.addEventListener('click', async () => {
if (childContainer.classList.contains('visible')) {
childContainer.classList.remove('visible');
toggle.classList.remove('expanded');
} else {
toggle.classList.add('expanded');
childContainer.classList.add('visible');
if (!loaded) {
childContainer.innerHTML = '<li class="mp-tree-msg">Loading...</li>';
try {
const res = await api.get({
action: 'query',
generator: 'categorymembers',
gcmtitle: 'Category:' + catName,
gcmtype: 'subcat',
gcmlimit: 50,
prop: 'categoryinfo',
format: 'json'
});
const pages = (res.query && res.query.pages) ? Object.values(res.query.pages) : [];
pages.sort((a, b) => a.title.localeCompare(b.title));
childContainer.innerHTML = '';
if (pages.length === 0) {
childContainer.innerHTML = '<li class="mp-tree-msg empty">No subcategories</li>';
toggle.classList.add('empty');
} else {
pages.forEach(child => {
// Strip "Category:" prefix for display
const childName = child.title.replace(/^Category:/, '');
const childHasSubcats = child.categoryinfo && child.categoryinfo.subcats > 0;
childContainer.appendChild(createTreeNode(childName, childHasSubcats));
});
}
loaded = true;
} catch (err) {
console.error('Subcategory load error:', err);
childContainer.innerHTML = '<li class="mp-tree-msg error">Error loading.</li>';
}
}
}
});
}
return li;
}
performSearch('House');
});