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');
});