Last commit for src/scripts/suggest.js: 2addb500315b7393a90fe66431d7832b1e7386c7

Adjust copyrights years

Chris Pollett [2024-01-03 21:Jan:rd]
Adjust copyrights years
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2023  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program 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.
 *
 * This program 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 this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Sandhya Vissapragada, Chris Pollett
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2023s
 * @filesource
 */
/*
 * Update the version number manually when ever
 * suggest.js undergoes changes
 */
SUGGEST_VERSION_NO = 0;
/*
 * Constants for key codes will handle
 */
KeyCodes = new Object();
KeyCodes.UP_ARROW = 38;
KeyCodes.DOWN_ARROW = 40;
/*
 * Maximum number of search terms to display
 */
MAX_DISPLAY = 6;
/*
 * Maximum number of characters in query to do spellsheck for
 */
MIN_SPELL_CHECK_WIDTH = 40;
/*
 * Height of a search term in pixels
 */
FONT_HEIGHT = 24;
/*
 * Used to delimit the end of a term in a trie.
 * The value below is the default define. Might be set
 * what the trie object loaded says in loadTrie
 */
END_OF_TERM_MARKER = " ";
/*
 * Process to follow once onsubmit event is fired
 *
 * @param None
 * @return None
 */
corrected_query = "";
function processSubmit()
{
    updateLocalStorage();
}
/*
 *
 */
function resetSuggest()
{
    let suggest_dropdown = elt("suggest-dropdown");
    suggest_dropdown.className = "";
    suggest_dropdown.style.height = "0";
    suggest_dropdown.style.visibility = "hidden";
    elt('query-field').value = "";
    document.activeElement.blur();
    return false;
}
/*
 * Steps to follow every time a key is up from the user end
 * Handles up/dowm arrow keys
 *
 * @param Event event current event
 * @return String text_field Current value from the search box
 *
 */
function onTypeTerm(event, text_field)
{
    let key_code_pressed;
    let term_array;
    let input_term = text_field.value.trim();
    let suggest_results = elt("suggest-results");
    let suggest_dropdown = elt("suggest-dropdown");
    let query = elt("query-field").value;
    let scroll_pos = 0;
    let tmp_pos = 0;
    let local_count = 0;
    locale_terms = new Object();
    local_terms_present = false;
    local_suggest = true;
    search_list_array = new Object();
    scroll_horz = false;

    out_query = false;
    if (typeof transliterate == 'function') {
        out_query = transliterate(query);
    }
    if (out_query && out_query.length > 0)
    {
       input_term = out_query;
    }
    //To find the length of an associative array
    Object.size = function(obj) {
        let size = 0, key;
        for (key in obj) {
            if (obj.hasOwnProperty(key)) size++;
        }
        return size;
    };
    concat_term = "";
    if (window.event) { // IE8 and earlier
        key_code_pressed = event.keyCode;
    } else if (event.which) { // IE9/Firefox/Chrome/Opera/Safari
        key_code_pressed = event.which;
    }
    term_array = input_term.split(" ");
    concat_array = input_term.split(" ", term_array.length - 1);
    if (input_term != "") {
        for (let i = 0; i < concat_array.length; i++) {
            concat_term += concat_array[i] + " ";
        }
        concat_term = concat_term.trim();
    }
    input_term = term_array[term_array.length - 1].trim(" ");
    // behavior if typing keys other than up or down (notice call termSuggest)
    if (key_code_pressed != KeyCodes.DOWN_ARROW &&
            key_code_pressed != KeyCodes.UP_ARROW) {
        search_list = "";
        // First search the local storage to fetch the suggestions
        if (typeof localStorage != 'undefined') {
            let locale_ver = locale+'_' + SUGGEST_VERSION_NO;
            if (localStorage[locale_ver] == null) {
                localStorage.clear();
                count = 0;
            } else if (localStorage[locale_ver] != null) {
                split_str = localStorage[locale_ver].split("@@");
                locale_terms = JSON.parse(split_str[1]);
                local_dict =JSON.parse(localStorage[locale_ver].split("@@", 1));
                if (local_dict != null) {
                    local_terms_present = true;
                    termSuggest(local_dict, input_term);
                    local_terms_present = false;
                }
                let sorted_local = sortLocalTerms();
                if (Object.size(search_list_array) > 0) {
                    search_list = "";
                    for (let i = 0; i < sorted_local.length ; i++) {
                        let split_array = sorted_local[i].split('*');

                        if (search_list_array[split_array[1]] != null) {
                            search_split =
                            search_list_array[split_array[1]].split("_");
                            search_list +=  "<li><span id='term" +local_count+
                                "' class='unselected' onclick='" +
                                "setSelectedTerm(\"" + local_count +
                                "\", \"selected\", true)' " +
                                "title='" + search_split[0] + "' " +
                                "onmouseover='setSelectedTerm(\"" +
                                local_count + "\",\"selected\")'" +
                                "onmouseout='setSelectedTerm(\""+
                                local_count+"\",\"unselected\")'" +
                                "ondblclick='termDblClick(\"" + search_split[0]+
                                "\",this.id)' >" + search_split[1] +
                                "</span></li>";
                            local_count++;
                        }

                    }
                }
                local_suggest = false;
            }
        }
        count = local_count;
        // Now search the actual dictionary trie
        termSuggest(dictionary, input_term);
        // insert nbsp of the number of suggestions are less than MAX_DISPLAY
        short_max = MAX_DISPLAY - count;
        for (let i = 0; i < short_max; i++) {
            search_list += "<li><span class='unselected'>&nbsp;</span></li>";
        }
        if (count < 1) {
            search_list = "";
        }
        suggest_dropdown.scrollTop =  0;
        suggest_results.innerHTML = search_list;
        //sort the list
        let count_tmp=0;
        Array.from(suggest_results.getElementsByTagName("LI"))
        .sort((a, b) => b.getAttribute("data-frequency")
            -a.getAttribute("data-frequency"))
        .forEach(li => {
            li.firstChild.id=("term"+count_tmp);
            count_tmp+=1;
            suggest_results.appendChild(li);
        });
        cursor_pos = -1;
        num_items = count;
        if (num_items == 0 || search_list == "") {
            suggest_dropdown.className = "";
            suggest_dropdown.style.height = "0";
            suggest_dropdown.style.visibility = "hidden";
            suggest_results.style.visibility = "hidden";
        } else {
            suggest_dropdown.className = "dropdown";
            suggest_results.style.visibility = "visible";
            suggest_dropdown.style.visibility = "visible";
            suggest_dropdown.style.height = (FONT_HEIGHT * MAX_DISPLAY) + "px";
            if (scroll_horz) {
                suggest_dropdown.style.overflowX = "scroll";
            } else {
                suggest_dropdown.style.overflowX = "hidden";
            }
        }
    }
    // behavior on up down arrows
    if (suggest_results.style.visibility == "visible") {
        if (key_code_pressed == KeyCodes.DOWN_ARROW) {
            if (cursor_pos < 0) {
                cursor_pos = 0;
                setSelectedTerm(cursor_pos, "selected", true);
            } else {
                if (cursor_pos < num_items - 1) {
                    setSelectedTerm(cursor_pos, "unselected");
                    cursor_pos++;
                }
                setSelectedTerm(cursor_pos, "selected", true);
            }
            scroll_count = 1;
            scroll_pos = (cursor_pos - MAX_DISPLAY >= 0) ?
                (cursor_pos - MAX_DISPLAY + 1) : 0;
            suggest_dropdown.scrollTop = scroll_pos * FONT_HEIGHT;
        } else if (key_code_pressed == KeyCodes.UP_ARROW) {
            if (cursor_pos < 0) {
                cursor_pos = 0;
                setSelectedTerm(cursor_pos, "selected", true);
            } else {
                if (cursor_pos > 0) {
                    setSelectedTerm(cursor_pos, "unselected");
                    cursor_pos--;
                }
                setSelectedTerm(cursor_pos, "selected", true);
            }
            scroll_pos = (cursor_pos - MAX_DISPLAY + scroll_count >= 0) ?
                (cursor_pos - MAX_DISPLAY + scroll_count) : 0;
            scroll_count = (MAX_DISPLAY > scroll_count) ? scroll_count + 1:
                MAX_DISPLAY;
            suggest_dropdown.scrollTop = scroll_pos * FONT_HEIGHT;
        }
    }
}
/*
 * To correct the spelling of the query words
 *
 * @param String word Input word
 * @return String corrected_word Corrected word
 */
function correctSpelling(word)
{
    let prob = 0;
    let correct_threshold = 25;
    let trie_subtree = exist(dictionary, word);
    if (trie_subtree != false) {
        prob = parseInt(trie_subtree[END_OF_TERM_MARKER]);
        if (prob >= correct_threshold) {
            return word;
        }
    }
    let curr_prob = 0;
    let candidates = known(edits1(word));
    candidates.push(word);
    let corrected_word = "";
    // Use the frequencies to get the best match
    for (let i = 0; i < candidates.length; i++) {
        trie_subtree = exist(dictionary, candidates[i]);
        if (trie_subtree != false) {
            curr_prob = parseInt(trie_subtree[END_OF_TERM_MARKER]);
        }
        if (curr_prob > correct_threshold * prob) {
            correct_threshold = 1;
            prob = curr_prob;
            corrected_word = candidates[i];
        }
    }
    return corrected_word;
}
/*
 * Gets the candidates for the spell correction with edit
 * distance 1
 *
 * @param String word Input word
 * @return Array set Words with edit distance - 1
 */
function edits1(word)
{
    let splits = new Object();
    let deletes = new Array();
    let transposes =new Array();
    let replaces = new Array();
    let inserts = new Array();
    let j = 0;

    splits[""] = word;
    for (let i = 0; i < word.length; i++) {
        splits[word.substring(0, i + 1)] = word.substring(i+1, word.length);
    }
    // Deletes
    for (key in splits) {
        if (splits[key] != "") {
            deletes[j] = key + splits[key].substring(1);
            j++;
        }
    }
    // Transposes
    j = 0;
    for (key in splits) {
        if (splits[key].length > 1) {
            transposes[j] = key + splits[key].substring(1, 2) +
            splits[key].substring(0,1) + splits[key].substring(2);
            j++;
        }
    }
    // Replaces
    j = 0;
    for (key in splits) {
        if (splits[key] != "") {
            for (let i = 0;i < alpha.length; i++) {
                replaces[j] = key + alpha.substring(i,i+1) +
                 splits[key].substring(1);
                j++;
            }
        }
    }
    // Inserts
    j = 0;
    for (key in splits) {
        for (let i=0; i < alpha.length; i++) {
            inserts[j] = key + alpha.substring(i, i + 1) + splits[key];
            j++;
        }
    }
    let set =
        deletes.concat(transposes).concat(replaces).concat(inserts).unique();
    return set;
}
Array.prototype.unique = function() {
    let a = this.concat();
    for (let i = 0; i<a.length; ++i) {
        for (let j=i+1; j < a.length; ++j) {
            if (a[i] === a[j])
                a.splice(j, 1);
        }
    }
    return a;
}
/*
 * To get the set of words which are known from the dictionary
 *
 * @param Array words_ip array of words
 * @return Array known_words array of known words
 */
function known(words_ip)
{
    let known_words = new Array();
    let j=0;
    let ret_array;
    for (let i = 0; i < words_ip.length; i++) {
        ret_array = exist(dictionary, words_ip[i]);
        if (ret_array[END_OF_TERM_MARKER] != null) {
            known_words[j] = words_ip[i];
            j++;
        }
    }
    return known_words;
}
/*
 * To update the local storage with the previous query terms and
 * create a trie on those terms
 *
 */
function updateLocalStorage()
{
    let trie_to_store;
    trie_storage = {};
    let store_term = elt("query-field").value;
    let freq, k = 0;
    let sorted_locale_terms = new Array();
    if (localStorage) {
        if (locale_terms && locale_terms[store_term] == null) {
            locale_terms[store_term] = 1;
        } else {
            freq = parseInt(locale_terms[store_term]);
            freq++;
            locale_terms[store_term] = freq;
        }
        for (let key in locale_terms) {
            sorted_locale_terms[k] = key;
            k++;
        }
        sorted_locale_terms.sort();
        // Build the trie
        for (let i=0; i<sorted_locale_terms.length; i++) {
            let trie_word = sorted_locale_terms[i];
            let letters = trie_word.split("");
            let cur = trie_storage;
            for (let j=0; j < letters.length; j++) {
                let letter = encode(letters[j]);
                let pos = cur[ letter ];
                if (pos == null) {
                    if (j === letters.length - 1) {
                        cur = cur[ letter ] = {'$' : '$'};
                    } else {
                        cur = cur[ letter ] = {};
                    }
                } else if (pos === 0) {
                    cur = cur[ letter ] = { '$' : '$' };
                } else {
                    cur = cur[ letter ];
                }
            }
        }
    }
    trie_to_store = JSON.stringify(trie_storage);
    localStorage.setItem(locale + '_' + SUGGEST_VERSION_NO, trie_to_store +
     "@@" + JSON.stringify(locale_terms));
}
/*
 * Sort the local storage words based of number of times they are queried
 *
 * @return Array local storage words
 */
function sortLocalTerms()
{
    let local_storage_array = new Array();
    if (Object.size(locale_terms) > 0) {
        let j = 0;
        for (let key in locale_terms) {
            local_storage_array[j] = locale_terms[key] + "*"+ key;
            j++;
        }
    }
    local_storage_array.sort(termFrequencyComparison);
    local_storage_array.reverse();
    return local_storage_array;
}
/*
 * Callback used by a sort call in sortLocalTerms to compare two
 * string where before the * in the string is a term and after is a frequency
 *
 * @param String a in format described above
 * @param String b in format described above
 * @return number 0 - if same frequency, negative if b has larger frequency,
 *     positive otherwise
 */
function termFrequencyComparison(a, b)
{
    let split_array1 = a.split('*');
    let split_array2 = b.split('*');
    let val1 = parseInt(split_array1[0]);
    let val2 = parseInt(split_array2[0]);
    return (val1 - val2);
}
/*
 * To select an suggest value while up/down arrow keys are being used
 * and place in the search box
 *
 * @param int pos index in the list items of suggest terms
 * @param String class_value value for CSS class attribute for that list item
 */
function setSelectedTerm(pos, class_value, title_flag)
{
    let query_field_object = elt("query-field");
    if (typeof title_flag != 'undefined') {
        query_field_object.value = elt("term" + pos).title;
    }
    elt("term" + pos).className = class_value;
}

/*
 * To selects a term from the suggest dropdownlist and performs as search
 *
 * @param String term locwhat was clicked on
 */
function termDblClick(term, termid)
{
    let results_dropdown = elt("suggest-results");
    let query_field_object = elt("query-field");
    query_field_object.value = term;
    results_dropdown.innerHTML = "";
    elt("suggest-dropdown").style.display = "none";
    elt("search-form").submit();
}
/*
 * Fetch words from the Trie and add to seachList with <li> </li> tags
 *
 * @param Array trie_array contains all search terms
 * @param String parent_word the prefix want to find sub-term for in trie
 * @param String highlighted_word parent_word, root_word + "<b>" + rest of
 *  parent
 */
function getTrieTerms(trie_array, parent_word, highlighted_word)
{
    let search_terms;
    let highlighted_terms;

    if (trie_array != null) {
        for (key in trie_array) {
            if (key != END_OF_TERM_MARKER ) {
                getTrieTerms(trie_array[key], parent_word + key,
                        highlighted_word + key);
            } else {
                if ( (locale_terms[decode(parent_word)] == null
                     && local_terms_present == false)) {
                    search_terms = concat_term.trim() + " " +
                        decode(parent_word);
                    search_terms = search_terms.trim();
                    highlighted_terms = concat_term.trim() + " " +
                    decode(highlighted_word) + "</b>";
                    search_list +=  "<li data-frequency="+trie_array[key]+
                        "><span class='unselected' onclick='setSelectedTerm(" +
                        "this.id.substr(4), \"selected\", true)' " +
                        "title='" + search_terms + "' " +
                        "onmouseover='setSelectedTerm(this.id.substr(4),"+
                        "\"selected\")'" +
                        "onmouseout='setSelectedTerm(this.id.substr(4),"+
                        "\"unselected\")'" +
                        "ondblclick='termDblClick(\""+search_terms
                        +"\",this.id)'"+
                        ">" + highlighted_terms + "</span></li>";
                    count++;
                    //handle long suggests phrases with horizontal scrollbar
                    if (search_terms.length * 24 > 1200 &&  !scroll_horz)
                        scroll_horz = true;
                } else if (local_terms_present == true) {
                    search_terms = concat_term.trim() + " "
                     + decode(parent_word);
                    search_terms = search_terms.trim();
                    highlighted_terms = concat_term.trim() + " "
                    + decode(highlighted_word) + "</b>";
                    search_list_array[decode(parent_word)] = search_terms +
                     "_" +highlighted_terms;
                    //handle long suggests phrases with horizontal scrollbar
                    if (search_terms.length * 24 > 1200 &&  !scroll_horz)
                        scroll_horz = true;
                }
            }
        }
    }
}
/*
 * Returns the sub trie_array under term in
 * trie_array. If term does not exist in the trie_array
 * returns false
 *
 * @param String term  what to look up
 * @return Array trie_array sub trie_array under term
 */
function exist(trie_array, term)
{
    if (trie_array == null) {
        return false;
    }
    for (let i = 0; i < term.length; i++) {
        tmp = getUnicodeCharAndNextOffset(term, i);
        if (tmp == false) return false;
        next_char = tmp[0];
        i = tmp[1];
        enc_char = encode(next_char);
        trie_array = trie_array[enc_char];
        if (trie_array == null) {
            return false;
        }
    }
    return trie_array;
}
/*
 * Entry point to find word completions/suggestions. Finds the portion of
 * trie_array beneath term. Then using this subtrie get the first six entries.
 * Six is specified in get values.
 *
 * @param Array trie_array - a nested array represent a trie
 * @param String term - what to look up suggestions for
 * @sideeffect global Array search_list has list of first six entries
 */
function termSuggest(trie_array, term)
{
    last_word = false;
    if (local_suggest == true) {
        count = 0;
        search_list = "";
    }
    // For US english ignore the case
    if (locale == 'en-US') {
        term = term.toLowerCase();
    }
    let tmp;
    if (trie_array == null) {
        return false;
    }
    if ((term.length) > 0) {
        trie_array = exist(trie_array, term);
    }
    else {
        return false;
    }
    getTrieTerms(trie_array, term, term + "<b>");
}

/* wrappers to save typing */
function decode(str) {
    str = str.replace(/\+/g, '%20');
    return decodeURIComponent(str);
}
/* wrappers to save typing */
function encode(str)
{
    str = encodeURIComponent(str);
    str = str.replace(/\'/g, '%27'); // encodeURIComponent doesn't convert
    return str;
}
/*
 * Extract next Unicode Char beginning at offset i in str returns Array
 * with this character and the next offset
 *
 * This is based on code found at:
 * https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects
 * /String/charAt
 *
 * @param String str what to get the next character out of
 * @param int i current offset into str
 * @return Array pair of Unicode character beginning at i and the next offset
 */
function getUnicodeCharAndNextOffset(str, i)
{
    let code = str.charCodeAt(i);
    if (isNaN(code)) {
        return '';
    }
    if (code < 0xD800 || code > 0xDFFF) {
        return [str.charAt(i), i];
    }
    if (0xD800 <= code && code <= 0xDBFF) {
        if (str.length <= i + 1) {
            return false;
        }
        let next = str.charCodeAt(i + 1);
        if (0xDC00 > next || next > 0xDFFF) {
            return false;
        }
        return [str.charAt(i) + str.charAt(i + 1), i + 1];
    }
    if (i === 0) {
        return false;
    }
    let prev = str.charCodeAt(i-1);
    if (0xD800 > prev || prev > 0xDBFF) {
        return false;
    }
    return [str.charAt(i + 1), i + 1];
}
/*
 * Load the Trie during the launch of website
 * Trie's are represented using nested arrays.
 */
function loadFiles()
{
    let request = makeRequest();
    let trie_loc = "";
    if (request) {
        request.onreadystatechange = function() {
            if (request.readyState == 4 && request.status == 200 &&
                request.responseText != "") {
                trie = JSON.parse(request.responseText);
                dictionary = trie["trie_array"];
                END_OF_TERM_MARKER = trie["end_marker"];
                if (typeof alpha != 'undefined')
                    spellCheck();
            }
            END_OF_TERM_MARKER = (typeof END_OF_TERM_MARKER == 'undefined') ?
                ' ' : END_OF_TERM_MARKER;
        }
        locale = document.documentElement.lang;
        if (locale) {
            let redirects = document.getElementsByTagName(
                "html")[0].getAttribute("data-redirect");
            let base_url = document.getElementsByTagName(
                "html")[0].getAttribute("data-base");
            if (redirects && base_url) {
                trie_loc = base_url + "wd/suggest/" + locale;
            } else {
                trie_loc = (base_url) ? base_url : "./";
                trie_loc += "?c=resource&a=suggest&locale=" + locale;
            }
            request.open("GET", trie_loc, true);
            request.send();
        }
    }
}
/*
 * To process spell correction
 */
function spellCheck()
{
    let reference_node;
    let spell_link;
    let csrf_token;
    if (document.getElementsByClassName) {
        reference_node = document.getElementsByClassName("search-results")[0];
    }
    if (reference_node) {
        let corrected_spell = elt("spell-check");
        /* corrected_spell might not be present if WORD_SUGGEST off or on
           a trending or chart page
         */
        if (!corrected_spell) {
            return;
        }
        let logged_in = (elt("csrf-token") !== null);
        if (logged_in) {
            csrf_token = elt("csrf-token").value;
        }
        let its_value = elt("its-value").value;
        let query = elt("query-field").value;
        if (query == "") {
            return;
        }
        let ret_array;
        let ret_word;
        if (typeof localStorage != 'undefined') {
            let locale_ver = locale + '_' + SUGGEST_VERSION_NO;
        }
        if (typeof locale_ver != 'undefined' &&
            localStorage[locale_ver] != null) {
            split_str = localStorage[locale_ver].split("@@");
            locale_terms = JSON.parse(split_str[1]);
            if (locale_terms[query] > 5) {
                return; // search for a lot so don't suggest
            }
        }
        // avoid suggesting if query contains meta words
        if (query.indexOf(":") !== -1) {
            return;
        }
        let term_array = query.split(" ");
        let num_terms = term_array.length;
        for (let i = 0; i < num_terms; i++) {
            ret_word = "";
            ret_word = correctSpelling(term_array[i].toLowerCase());
            if (ret_word.trim(" ") == "") {
                corrected_query += term_array[i] + " ";
            } else {
                corrected_query += ret_word + " ";
            }
        }
        if (query.length > MIN_SPELL_CHECK_WIDTH) {
            return;
        }
        if (corrected_query.trim() != query.toLowerCase().trim()) {
            if (logged_in) {
                let token_name = csrf_name;
                spell_link = "?" + token_name + "=" + csrf_token + "&q="
                    + corrected_query;
            } else {
                spell_link = "?q=" + corrected_query;
            }
            setDisplay('spell-check', true);
            corrected_spell.innerHTML = "<b>" + local_strings.spell
                +": <a rel='nofollow' href='" + spell_link +
                "'>"  + corrected_query + "</a></b>";
        }
    }
}
tag("body")[0].onload = loadFiles;
let ip_field = elt("query-field");
if (ip_field != null) {
    ip_field.onpaste = function(e) {
        setTimeout(function(){
            onTypeTerm(e,ip_field);
            }, 0);
    }
    ip_field.oncut = function(e) {
        setTimeout(function(){
                onTypeTerm(e,ip_field);
                }, 0);
    }
}
ViewGit