admin/tool/componentlibrary/amd/src/search.js

// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Interface to the Lunr search engines.
 *
 * @module     tool_componentlibrary/search
 * @copyright  2021 Bas Brands <bas@moodle.com>
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

import lunrJs from 'tool_componentlibrary/lunr';
import selectors from 'tool_componentlibrary/selectors';
import Log from 'core/log';
import Notification from 'core/notification';
import {enter, escape} from 'core/key_codes';

let lunrIndex = null;
let pagesIndex = null;

/**
 * Get the jsonFile that is generated when the component library is build.
 *
 * @method
 * @private
 * @param {String} jsonFile the URL to the json file.
 * @return {Object}
 */
const fetchJson = async(jsonFile) => {
    const response = await fetch(jsonFile);

    if (!response.ok) {
        Log.debug(`Error getting Hugo index file: ${response.status}`);
    }

    return await response.json();
};

/**
 * Initiate lunr on the data in the jsonFile and add the jsondata to the pagesIndex
 *
 * @method
 * @private
 * @param {String} jsonFile the URL to the json file.
 */
const initLunr = jsonFile => {
    fetchJson(jsonFile).then(jsondata => {
        pagesIndex = jsondata;
        // Using an arrow function here will break lunr on compile.
        lunrIndex = lunrJs(function() {
            this.ref('uri');
            this.field('title', {boost: 10});
            this.field('content');
            this.field('tags', {boost: 5});
            jsondata.forEach(p => {
                this.add(p);
            });
        });
        return null;
    }).catch(Notification.exception);
};

/**
 * Setup the eventlistener to listen on user input on the search field.
 *
 * @method
 * @private
 */
const initUI = () => {
    const searchInput = document.querySelector(selectors.searchinput);
    searchInput.addEventListener('keyup', e => {
        const query = e.currentTarget.value;
        if (query.length < 2) {
            document.querySelector(selectors.dropdownmenu).classList.remove('show');
            return;
        }
        renderResults(searchIndex(query));
    });
    searchInput.addEventListener('keydown', e => {
        if (e.keyCode === enter) {
            e.preventDefault();
        }
        if (e.keyCode === escape) {
            searchInput.value = '';
        }
    });
};

/**
 * Trigger a search in lunr and transform the result.
 *
 * @method
 * @private
 * @param  {String} query
 * @return {Array} results
 */
const searchIndex = query => {
    // Find the item in our index corresponding to the lunr one to have more info
    // Lunr result:
    //  {ref: "/section/page1", score: 0.2725657778206127}
    // Our result:
    //  {title:"Page1", href:"/section/page1", ...}

    return lunrIndex.search(query + ' ' + query + '*').map(result => {
        return pagesIndex.filter(page => {
            return page.uri === result.ref;
        })[0];
    });
};

/**
 * Display the 10 first results
 *
 * @method
 * @private
 * @param {Array} results to display
 */
const renderResults = results => {
    const dropdownMenu = document.querySelector(selectors.dropdownmenu);
    if (!results.length) {
        dropdownMenu.classList.remove('show');
        return;
    }

    // Clear out the results.
    dropdownMenu.innerHTML = '';

    const baseUrl = M.cfg.wwwroot + '/admin/tool/componentlibrary/docspage.php';

    // Only show the ten first results
    results.slice(0, 10).forEach(function(result) {
        const link = document.createElement("a");
        const chapter = result.uri.split('/')[1];
        link.appendChild(document.createTextNode(`${chapter} > ${result.title}`));
        link.classList.add('dropdown-item');
        link.href = baseUrl + result.uri;

        dropdownMenu.appendChild(link);
    });

    dropdownMenu.classList.add('show');
};

/**
 * Initialize module.
 *
 * @method
 * @param {String} jsonFile Full path to the search DB json file.
 */
export const search = jsonFile => {
    initLunr(jsonFile);
    initUI();
};