/** Search Engine * --------------------------------------------------------------------------- * * TODO: * CRITICAL: (optimization) * Search initialization uses quite a lot of network sources; * thus it should be started in a later time; * lets say ... after `x` seconds * or... when document/core-ui-elements are ready * * @parametres (json) : options * --- * @var {string} products_json : endpoint od product descriptions * @var {string} search_tag : selector of field that shall act as typeahead-suggestions * @var {string} visualize_search_results_url : url that will visualize the sended "results-page" * @var {int} max_list : max-size of (rated) results expected * @var {int} tolerance * @var {boolean} debug : if true sends several debug console messages; if false mesagges are eliminated */ retrosearch_module({ products_json: 'https://storage.googleapis.com/pythia-files/uploads/json/emarket-products.json', search_tag: '#tagsInput', visualize_search_results_url: '/product_list', max_list: 24, tolerance: 42, debug: ((location.hostname == 'localhost') || (location.hostname == '127.0.0.1')) }); function retrosearch_module(options) { /** CONTENTS * * +1: Variables * * +2: Purify string functions * + keyboardize * + sanitize_GR * + clean * * +3: Supplementary function (vanilla js) * + ajax_get(url, callback) * + createNgram (fuzzy) * + checkSimilarity (fuzzy) * + check_match * + is_exact_match * + match_one * * +4: Actual data loading (async) * * +5: Suggestions Engine (jQuery) * + suggestions_engine * */ if (options.debug) console.log(`preparing retrosearch... (${+ new Date()})`); /** 1. VARIABLES * ------------------------------------------------------------------------- *////////////////////////////////////////////////////////////////////////// var _kwlinks; // keyword links (word-connections; imported via ajax-get) var _products = []; // all products (imported via ajax-get) // setup options var _maxResults = options.max_list; // limit suggestions var _blendProds = 4; // minimum final-produncts to blend with next-word suggestions var _timeout_ms = 100; // time (in ms) for the search engine to find matches (before rendering) var _allowFuzzy = true; // enable|disable fuzzy search var _fuzzyLimit = .5; // minimum bigram score for being considered a match var _Ngram_base = 2; // number of N in Ngram spliting algorithm var _isReady = false; // whether the searchbox is ready to be used // product keywords var keywordsURL = options.keywords_json; var cursor_on = { none: true }; // what product is highlighted; if not on product then { none: true } /** 3. SUPPLEMENTARY FUNCTIONS * ------------------------------------------------------------------------- *////////////////////////////////////////////////////////////////////////// // TODO: // exclude some generic non-critial words when proccessing user's query // ** example code to work with: // var ignoredKeys_kb = []; // keywords to ignore (in kb-format) // 'μας με σε για του της των από στο στον &'.split(' ').forEach(w => { ignoredKeys_kb.push(keyboardize(w)); }); // pure JS ajax GET request; return data as JSON // no fancy things like UTF8; if needed use base64 function ajax_get(url, callback) { var xmlhttp = new XMLHttpRequest(); xmlhttp.onreadystatechange = function() { if (xmlhttp.readyState == 4 && xmlhttp.status == 200) { try { var data = JSON.parse(xmlhttp.responseText); } catch(err) { if (options.debug) console.log(err.message + " in " + xmlhttp.responseText); return; } callback(data); } }; xmlhttp.open("GET", url, true); xmlhttp.send(); } // callback function for sorting resulrs per r (=rating) property function compare_rate(a,b) { return (a.r < b.r); } /** vwOverUnder * vertical-wise over/under * * @param {document.element} el : element to check * @param {document.element} container : container element (compairing) * @return {int} .. [-1|0|+1] * ..: 0 if element's heignt is completely into container's viewport * ..: -1 if element's heignt is over container's viewport * ..: +1 if element's heignt is under container's veiwport */ function vwOverUnder(el, container) { let eR = el.getBoundingClientRect(); let cR = container.getBoundingClientRect(); console.log('er|cr', eR, cR); let result = -1; // default: element is under container's viewport if (eR.y >= cR.y && eR.y + eR.height <= cR.y + cR.height - options.tolerance) { result = 0 // fully inside } else if (eR.y < cR.y) { result = -1 } return result; } /** 4. ACTUAL DATA LOADING * ------------------------------------------------------------------------- *////////////////////////////////////////////////////////////////////////// // TODO: // control completion of async loads via .then() rather by this custom structrure // Need to rewrite the folllowing code ............................. from here // ........................................................................... // ........................................................................... /** workline * ------------------------------------------------------------------------- * custom object/data-structure * to track status/completion of async svents * (it does the job using a special set method) */ var workline = { trackerJL : 0, set jsonLoaded(x) { this.trackerJL = x; // fire event on certain values if (x == 2) { if (options.debug) console.info('suggestion-engine requirements fulfilled'); _isReady = true; // code to execute // ... } }, get jsonLoaded() { return this.trackerJL; } }; /** LOAD DATA (from endoints) * --------------------------------------------------------------------------- */ function load_store_products(storeID) { let prData = getSessionObj('prd'); if (prData !== null) { _products = prData; if (options.debug) console.log(`...products fetched from cache (${+ new Date()})`); workline.jsonLoaded++; } else { ajax_get(options.products_json, function(data) { // get stor's _products _products = data; _products.forEach(p => {p.kb = keyboardize(p.w).toLowerCase()} ); setSessionObj('prd', _products); if (options.debug) console.log(`...products loaded; (${+ new Date()})`); workline.jsonLoaded++; CURRENT_STOREs_CATALOG = storeID; }); } } var STORE = { id: 904 }; if (STORE.id != 0) load_store_products(STORE.id); // ........................................................................... // ........................................................................... // ................................................................ up to here /** 5. SEARCH ENGINE * ------------------------------------------------------------------------- *////////////////////////////////////////////////////////////////////////// workline.jsonLoaded++; // notify workline that jQuery is ready!! // suggestions engine ////////////////////////////////////////////////// // --- function suggestions_engine(qOrig) { var results = []; // suggestions to respond var pot = []; pot.length = 0; var space_ended = (qOrig.slice(-1) == ' ') ? true : false; // clean and sanitize and mark links onto q(uery) string var q = keyboardize( sanitize_GR( clean_text(qOrig.trim()) ) ).trim(); // TODO: // construct direct-linked words // = do unequivocally replaces // steps: // 1. replace accented vowels with non accented ones // 2. replace `/some pattern/gi , 'SOME-REPLACE-PATTERN'` var qAr = q.split(' '); // split to words /// if (space_ended) qAr.push(' '); // if space-end existed, push a space to query array /// /// if (qAr.slice(-1) == "") { /// qAr.pop(); /// } if (options.debug) console.log('*** init SEARCH QUERY:', qOrig, 'search:', qAr); pot = _products; // potential results // NOTE: CRITICAL: BY REFERENCE var wi = 0; // word index (from list) var wc = qAr.length; qAr.forEach( w => { if (options.debug) console.log('...testing', w); let sf = []; // (matches) so far let mi; // position of match wi++; pot.forEach( it => { let matched = false; let tester = ' '+ it.kb + ' '; // reset previous history and ratings if (wi == 1) { it.r = 0; it.history = []; } // rate word-match > start-match > simple-match // ... up to 8 points if (tester.indexOf(' '+ w +' ') != -1) { it.r += 9; it.history.push({ w: w, rate: 9 }); matched = true; } else if (tester.indexOf(' '+ w) != -1) { it.r += 5; it.history.push({ w: w, rate: 5 }); matched = true; } else if (tester.indexOf(w) != -1) { it.r += 2; it.history.push({ w: w, rate: 2 }); matched = true; } // rate `near-to-start` matching .. up to 7p // rate `earlyness` of word in query .. up to 7p if ((mi = tester.indexOf(' '+w)) != -1) { let fc1 = 100 - ((mi < 99) ? mi : 99); // near-to-start factor let fc2 = wc - wi + 1; // query earlyness factor let r1 = Math.floor(7*fc1/100); let r2 = Math.floor(7*fc2/wc); it.r += (r1 + r2); it.history.push({ w: w, left: [fc1, r1], early: [fc2, r2] }); } if (matched) sf.push(it); }); if ((sf.length > (_maxResults + Math.floor(_maxResults/2))) || (wi == 1) ) { // ..if pot has a fair amount (= max + 50%) of results // ..or these are results of '1st-query-word' // set sf as new source pot.lenght = 0; pot = []; pot = JSON.parse(JSON.stringify(sf)); // copy by value } else { // else.. keep the source list and increase of 'so-far rating' // console.log('found small list', sf, pot) pot.forEach( it => { sf.forEach( si => { if (it.id == si.id) { it.r += 10; it.history.push({ w: w, plus: '+10'}); } }); }); } }); // sort results, get max-list of best rated results = (pot.length > _maxResults) ? pot.sort(compare_rate).slice(0, _maxResults) : pot.sort(compare_rate) if (options.debug) console.log(results); return results; } // suggestions caller (router function) // arguments: // * qOrig : original query string // ** list : callback array structure to host results // --- var isuggest = function(qOrig, list) { var results; qSanit = sanitize_GR(qOrig); // sanitize greek accended chars // TODO: // check if all source-lists are ready // if not you need to wait ... // via async promishes or synced timouts /// // DEPRECATED: Suppport for eys-code search /// // if query seems to be some king of 'code/id' /// if (qSanit.length>2 && qSanit.match(/^[0-9]+$/) != null) { /// results = search_by_code(qOrig); /// /// } else { /// // string match procedure with suggestions engine results = suggestions_engine(qOrig); /// } list(results); } // var rsfoot = document.createElement('div'); // rsfoot.setAttribute("id", "retrosearch-footer"); // $('#retrosearch-footer').html('Multi Search'); /* jQuery(function() { // on document ready code ////////////////////////// // UI-dependent code // uses reference to specific document element (passed via options) // ----------------------------------------------------------------------- const searchBox = $(options.search_tag); searchBox.typeahead( { hint: true, highlight: true, minLength: 1 }, { limit: _maxResults, // +1 for extra button // name: 'kwlinks', displayKey: 'w', source: isuggest, templates: { suggestion: function(data) { return `
${data.w}Alfa, Beta and Gamma
`; }, footer: '', empty: '
Δεν υπάρχει στο κωδικολόγιο του καταστήματος
' } } ) .bind("typeahead:selected", function(obj, datum, name) { if (datum.hasOwnProperty('id')) { // ** selected: PRODUCT // $('#js-add-product-to-order').attr('disabled', false); // fill_fields(datum.id, datum.sc, datum.x, datum.w, 1, datum.bc, datum.eu, datum.stk, datum.img); if (options.debug) console.log('selected: ', datum.id, datum.w); // GOTO product page window.location.href = 'product_list?product=id-' + datum.id; // $("#product-quantity").trigger('focus'); } else { // selected: SUGGESTION $('.typeahead').typeahead('val','').trigger('blur'); $('.typeahead').typeahead('val', datum.w +' ') .trigger("query"); // give some time to the engine to calculate results // then fire focus again... setTimeout(() => { $('.typeahead').trigger('focus'); }, _timeout_ms); } }) .bind("typeahead:cursorchange", function( event, obj) { // track cursor-chane to handle special keys after a final product is selected if (typeof obj === 'object' && !Array.isArray(obj) && obj !== null) { // console.log(obj); // var liObj = $(`.tt-menu .tt-dataset .tt-suggestion[data-id="${obj.id}"]`) // var c = liObj.html(); // liObj.html = c + '\n\n_'; // liObj.html = c; var y = $('.tt-menu').scrollTop(); let ou = vwOverUnder( document.querySelector(`.tt-menu .tt-suggestion[data-id="${obj.id}"]`), document.querySelector('.tt-menu') ); console.log('y:',y, 'obj:', obj, 'o/u:', ou); $('.tt-menu').scrollTop( y - ou * options.tolerance); // TODO: // need to park the cursorChanged-div into tt-menu veiwport (??) // check: https://stackoverflow.com/questions/75002332/check-if-child-element-is-100-visible-inside-a-parent-div-that-has-overflow-hid } // TODO: // handle exception where obj is undefined; // this occures... // when cusror returns from suggestions list back to the search field if (typeof obj === 'undefined') cursor_on = { none: true }; else if (obj.hasOwnProperty('id')) cursor_on = obj; else cursor_on = { none: true }; }) .bind('typeahead:opened', function(e) { console.log('attach multisearch option if not exist'); }); // TRACK user search attempt /////////////////////////////////////////////// // $('.typeahead').on('typeahead:opened', function(e) { // console.log('attach multisearch option if not exist'); // }); $('.typeahead').on('keyup', function(e) { if (_isReady) { // console.log('on:', cursor_on); // console.log('key:', e.key); if ((e.key == ' ') && cursor_on.hasOwnProperty('id')) { // product is actually selected var datum = cursor_on; // fill_fields(datum.id, datum.sc, datum.x, datum.w, 1, datum.bc, datum.eu, datum.stk, datum.img); $("#product-quantity").trigger('focus'); e.preventDefault(); } else { if (e.key === "Enter") { // if curson is not on some option if ((typeof curson_on === 'undefined') || (curson_on.none == true)) { // do a common search if (options.debug) console.log('Do a Non-Suggestions search', searchBox.val()); // DEPRICATED: var location = encodeURI('/product_list?productSearch=%'+ searchBox.val() +'%'); // search_results = common_search(searchBox.val()); // TODO: console.log('DO A COMMON_SEARCH'); } else { // launch a product page var location = '/product_list?product=id-'+ cursor_on.id; if (options.debug) console.log('product', cursor_on.id, cursor_on.w); // TODO: console.log('GO TO PRODUCT PAGE', location) } // window.location.href = location; } } } }); }); */ // sub-module (start) //////////////////////////////////////////////////////////////////////////// function update_common_search_results(q, results) { let queries = getSessionObj('sr'); let newSRlist = []; let isnewQ = true; if (queries === null) { setSessionObj('sr', [{ q: q, result: result, t: + new Date() }]); return true; } else { queries.forEach(it => { if (it.q == q) { newSRlist.push({ q:q, result: result, t: + new Date() }); isnewQ = false; } else { newSRlist.push(it); } }); if (isnewQ) { newSRlist.push({ q:q, result: result, t: + new Date() }); } return true; } } //////////////////////////////////////////////////////////////////////////// // sub-module (end) function common_search(query) { // clear ; sanitize ; split var qAr = keyboardize( sanitize_GR( clean_text(query) ) ).toLowerCase().split(' '); // if last item is empty, remove it if ((qAr.slice(-1) == ' ') || (qAr.slice(-1) == '')) qAr.pop() var results = _products; // for each key fitler results qAr.forEach( key => { results = key_sublist(key, results) }); // echo products (and prepare list to POST) var list_ = []; results.forEach( item => { if (options.debug) console.log(item.id, ':', item.w); list_.push(item.id) }) // *** TODO: keep results in local storage (or on session storage) // update_common_search_results(query, list_); let l = list_.join(','); var url = encodeURI(`${options.visualize_search_results_url}?search=${query}&eys_code=${l}`); console.log('common search: search query > location = search') window.location.href = encodeURI(`${options.visualize_search_results_url}?search=${query}`); } /** return from list only items that include 'key' */ function key_sublist(key, list) { var result = []; list.forEach( item => { if (item.kb.includes(key)) { result.push(item); } }); return result; } } /** test script * --- * pl = getSessionObj('prd'); wl = ['coca', 'xvris', 'zaxarh', '2x1']; * pot = pl; * console.log(+ new Date()); * wl.forEach( w => { * let sf = []; * for(i=0 ; i < pot.length; i++) { if (pot[i].kb.includes(w)) sf.push(pot[i]); } * pot.lenght = 0; pot = sf; * }); * console.log(+ new Date(), pot); * * ** test performance for vs foreach * --- * pl = getSessionObj('prd'); console.log('started', + new Date()); * for(j=0; j<50; j++) { for(i=0; i { let x = it.kb; }); } * console.log('ended', + new Date()); **/