479 lines
14 KiB
JavaScript
479 lines
14 KiB
JavaScript
// This file contains the code that drives the search system UX.
|
|
// It's included in every HTML file.
|
|
// Depends on library files searchlib.js and ajax.js (and of course lunr.js)
|
|
|
|
// Ideas for improvments:
|
|
// - Include filtering buttons:
|
|
// - search only in the current module
|
|
// - have a query frontend that helps build complex queries
|
|
// - Filter out results that have score > 0.001 by default and show them on demand.
|
|
// - Should we change the default term presence to be MUST and not SHOULD ?
|
|
// -> Hack something like 'name index -value' -> '+name +index -value'
|
|
// -> 'name ?index -value' -> '+name index -value'
|
|
// - Highlight can use https://github.com/bep/docuapi/blob/5bfdc7d366ef2de58dc4e52106ad474d06410907/assets/js/helpers/highlight.js#L1
|
|
// Better: Add support for AND and OR with parenthesis, ajust this code https://stackoverflow.com/a/20374128
|
|
|
|
//////// GLOBAL VARS /////////
|
|
|
|
let input = document.getElementById('search-box');
|
|
let results_container = document.getElementById('search-results-container');
|
|
let results_list = document.getElementById('search-results');
|
|
let searchInDocstringsButton = document.getElementById('search-docstrings-button');
|
|
let searchInDocstringsCheckbox = document.getElementById('toggle-search-in-docstrings-checkbox');
|
|
var isSearchReadyPromise = null;
|
|
|
|
// setTimeout variable to warn when a search takes too long
|
|
var _setLongSearchInfosTimeout = null;
|
|
|
|
//////// UI META INFORMATIONS FUNCTIONS /////////
|
|
|
|
// Taken from https://stackoverflow.com/a/14130005
|
|
// For security.
|
|
function htmlEncode(str) {
|
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function _setInfos(message, box_id, text_id) {
|
|
document.getElementById(text_id).textContent = message;
|
|
if (message.length>0){
|
|
document.getElementById(box_id).style.display = 'block';
|
|
}
|
|
else{
|
|
document.getElementById(box_id).style.display = 'none';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set the search status.
|
|
*/
|
|
function setStatus(message) {
|
|
document.getElementById('search-status').textContent = message;
|
|
}
|
|
|
|
/**
|
|
* Show a warning, hide warning box if empty string.
|
|
*/
|
|
function setWarning(message) {
|
|
_setInfos(message, 'search-warn-box', 'search-warn');
|
|
}
|
|
|
|
/**
|
|
* Say that Something went wrong.
|
|
*/
|
|
function setErrorStatus() {
|
|
resetLongSearchTimerInfo()
|
|
setStatus("Something went wrong.");
|
|
setErrorInfos();
|
|
}
|
|
|
|
/**
|
|
* Show additional error infos (used to show query parser errors infos) or tell to go check the console.
|
|
* @param message: (optional) string
|
|
*/
|
|
function setErrorInfos(message) {
|
|
if (message != undefined){
|
|
setWarning(message);
|
|
}
|
|
else{
|
|
setWarning("Error: See development console for details.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset the long search timer warning.
|
|
*/
|
|
function resetLongSearchTimerInfo(){
|
|
if (_setLongSearchInfosTimeout){
|
|
clearTimeout(_setLongSearchInfosTimeout);
|
|
}
|
|
}
|
|
function launchLongSearchTimerInfo(){
|
|
// After 10 seconds of searching, warn that this is taking more time than usual.
|
|
_setLongSearchInfosTimeout = setTimeout(setLongSearchInfos, 10000);
|
|
}
|
|
|
|
/**
|
|
* Say that this search is taking longer than usual.
|
|
*/
|
|
function setLongSearchInfos(){
|
|
setWarning("This is taking longer than usual... You can keep waiting for the search to complete, or retry the search with other terms.");
|
|
}
|
|
|
|
//////// UI SHOW/HIDE FUNCTIONS /////////
|
|
|
|
function hideResultContainer(){
|
|
results_container.style.display = 'none';
|
|
if (!document.body.classList.contains("search-help-hidden")){
|
|
document.body.classList.add("search-help-hidden");
|
|
}
|
|
}
|
|
|
|
function showResultContainer(){
|
|
results_container.style.display = 'block';
|
|
updateClearSearchBtn();
|
|
}
|
|
|
|
function toggleSearchHelpText() {
|
|
document.body.classList.toggle("search-help-hidden");
|
|
if (document.body.classList.contains("search-help-hidden") && input.value.length==0){
|
|
hideResultContainer();
|
|
}
|
|
else{
|
|
showResultContainer();
|
|
}
|
|
}
|
|
|
|
function resetResultList(){
|
|
resetLongSearchTimerInfo();
|
|
results_list.innerHTML = '';
|
|
setWarning('');
|
|
setStatus('');
|
|
}
|
|
|
|
function clearSearch(){
|
|
stopSearching();
|
|
|
|
input.value = '';
|
|
updateClearSearchBtn();
|
|
}
|
|
|
|
function stopSearching(){
|
|
// UI
|
|
hideResultContainer();
|
|
resetResultList();
|
|
|
|
// NOT UI
|
|
_stopSearchingProcess();
|
|
}
|
|
|
|
function _stopSearchingProcess(){
|
|
abortSearch();
|
|
restartSearchWorker();
|
|
}
|
|
|
|
/**
|
|
* Show and hide the (X) button depending on the current search input.
|
|
* We do not show the (X) button when there is no search going on.
|
|
*/
|
|
function updateClearSearchBtn(){
|
|
|
|
if (input.value.length>0){
|
|
document.getElementById('search-clear-button').style.display = 'inline-block';
|
|
}
|
|
else{
|
|
document.getElementById('search-clear-button').style.display = 'none';
|
|
}
|
|
}
|
|
|
|
//////// SEARCH WARPPER FUNCTIONS /////////
|
|
|
|
// Values configuring the search-as-you-type feature.
|
|
var SEARCH_DEFAULT_DELAY = 100; // in miliseconds
|
|
var SEARCH_INCREASED_DELAY = 200;
|
|
var SEARCH_INDEX_SIZE_TRESH_INCREASE_DELAY = 10; // in MB
|
|
var SEARCH_INDEX_SIZE_TRESH_DISABLE_SEARCH_AS_YOU_TYPE = 20;
|
|
var SEARCH_AUTO_WILDCARD = true;
|
|
|
|
// Search delay depends on index size.
|
|
function _getIndexSizePromise(indexURL){
|
|
return httpGetPromise(indexURL).then((responseText) => {
|
|
if (responseText==null){
|
|
return 0;
|
|
}
|
|
let indexSizeApprox = responseText.length / 1000000; // in MB
|
|
return indexSizeApprox;
|
|
});
|
|
}
|
|
function _getSearchDelayPromise(indexURL){ // -> Promise of a Search delay number.
|
|
return _getIndexSizePromise(indexURL).then((size) => {
|
|
var searchDelay = SEARCH_DEFAULT_DELAY;
|
|
if (size===0){
|
|
return searchDelay;
|
|
}
|
|
if (size>SEARCH_INDEX_SIZE_TRESH_INCREASE_DELAY){
|
|
// For better UX
|
|
searchDelay = SEARCH_INCREASED_DELAY; // in miliseconds, this avoids searching several times when typing several leters very rapidly
|
|
}
|
|
return searchDelay;
|
|
});
|
|
}
|
|
|
|
function _getIsSearchReadyPromise(){
|
|
return Promise.all([
|
|
httpGetPromise("all-documents.html"),
|
|
httpGetPromise("searchindex.json"),
|
|
httpGetPromise("fullsearchindex.json"),
|
|
httpGetPromise("lunr.js"),
|
|
]);
|
|
}
|
|
|
|
// Launch search as user types if the size of the index is small enought,
|
|
// else say "Press 'Enter' to search".
|
|
function searchAsYouType(){
|
|
if (input.value.length>0){
|
|
showResultContainer();
|
|
}
|
|
_getIndexSizePromise("searchindex.json").then((indexSizeApprox) => {
|
|
if (indexSizeApprox > SEARCH_INDEX_SIZE_TRESH_DISABLE_SEARCH_AS_YOU_TYPE){
|
|
// Not searching as we type if "default" index size if greater than 20MB.
|
|
if (input.value.length===0){ // No actual query, this only resets some UI components.
|
|
launchSearch();
|
|
}
|
|
else{
|
|
setTimeout(() => {
|
|
_stopSearchingProcess();
|
|
resetResultList();
|
|
setStatus("Press 'Enter' to search.");
|
|
});
|
|
}
|
|
}
|
|
else{
|
|
launchSearch();
|
|
}
|
|
});
|
|
}
|
|
|
|
searchEventsEnv.addEventListener("searchStarted", (ev) => {
|
|
setStatus("Searching...");
|
|
});
|
|
|
|
var _lastSearchStartTime = null;
|
|
var _lastSearchInput = null;
|
|
/**
|
|
* Do the actual searching business
|
|
* Main entrypoint to [re]launch the search.
|
|
* Called everytime the search bar is edited.
|
|
*/
|
|
function launchSearch(noDelay){
|
|
let _searchStartTime = performance.now();
|
|
|
|
// Get the query terms
|
|
let _query = input.value;
|
|
|
|
// In chrome, two events are triggered simultaneously for the input event.
|
|
// So we discard consecutive (within the same 0.001s) requests that have the same search query.
|
|
if ((
|
|
(_searchStartTime-_lastSearchStartTime) < (0.001*1000)
|
|
) && (_query === _lastSearchInput) ){
|
|
return;
|
|
}
|
|
|
|
updateClearSearchBtn();
|
|
|
|
// Setup query meta infos.
|
|
_lastSearchStartTime = _searchStartTime
|
|
_lastSearchInput = _query;
|
|
|
|
if (_query.length===0){
|
|
stopSearching();
|
|
return;
|
|
}
|
|
|
|
if (!window.Worker) {
|
|
setStatus("Cannot search: JavaScript Worker API is not supported in your browser. ");
|
|
return;
|
|
}
|
|
|
|
resetResultList();
|
|
showResultContainer();
|
|
setStatus("...");
|
|
|
|
// Determine indexURL
|
|
let indexURL = _isSearchInDocstringsEnabled() ? "fullsearchindex.json" : "searchindex.json";
|
|
|
|
// If search in docstring is enabled:
|
|
// -> customize query function to include docstring for clauses applicable for all fields
|
|
let _fields = _isSearchInDocstringsEnabled() ? ["name", "names", "qname", "docstring"] : ["name", "names", "qname"];
|
|
|
|
resetLongSearchTimerInfo();
|
|
launchLongSearchTimerInfo();
|
|
|
|
// Get search delay, wait the all search resources to be cached and actually launch the search
|
|
return _getSearchDelayPromise(indexURL).then((searchDelay) => {
|
|
if (isSearchReadyPromise==null){
|
|
isSearchReadyPromise = _getIsSearchReadyPromise()
|
|
}
|
|
return isSearchReadyPromise.then((r)=>{
|
|
return lunrSearch(_query, indexURL, _fields, "lunr.js", !noDelay?searchDelay:0, SEARCH_AUTO_WILDCARD).then((lunrResults) => {
|
|
|
|
// outdated query results
|
|
if (_searchStartTime != _lastSearchStartTime){return;}
|
|
|
|
if (!lunrResults){
|
|
setErrorStatus();
|
|
throw new Error("No data to show");
|
|
}
|
|
|
|
if (lunrResults.length == 0){
|
|
setStatus('No results matches "' + htmlEncode(_query) + '"');
|
|
resetLongSearchTimerInfo();
|
|
return;
|
|
}
|
|
|
|
setStatus("One sec...");
|
|
|
|
// Get result data
|
|
return fetchResultsData(lunrResults, "all-documents.html").then((documentResults) => {
|
|
|
|
// outdated query results
|
|
if (_searchStartTime != _lastSearchStartTime){return;}
|
|
|
|
// Edit DOM
|
|
resetLongSearchTimerInfo();
|
|
displaySearchResults(_query, documentResults, lunrResults)
|
|
|
|
// Log stats
|
|
console.log('Search for "' + _query + '" took ' +
|
|
((performance.now() - _searchStartTime)/1000).toString() + ' seconds.')
|
|
|
|
// End
|
|
})
|
|
}); // lunrResults promise resolved
|
|
});
|
|
}).catch((err) => {_handleErr(err);});
|
|
|
|
} // end search() function
|
|
|
|
function _handleErr(err){
|
|
console.dir(err);
|
|
setStatus('')
|
|
if (err.message){
|
|
resetLongSearchTimerInfo();
|
|
setWarning(err.message) // Here we show the error because it's likely a query parser error.
|
|
}
|
|
else{
|
|
setErrorStatus();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Given the query string, documentResults and lunrResults as used in search(),
|
|
* edit the DOM to add them in the search results list.
|
|
*/
|
|
function displaySearchResults(_query, documentResults, lunrResults){
|
|
resetResultList();
|
|
documentResults.forEach((dobj) => {
|
|
results_list.appendChild(buildSearchResult(dobj));
|
|
});
|
|
|
|
if (lunrResults.length > 500){
|
|
setWarning("Your search yielded a lot of results! Maybe try with other terms?");
|
|
}
|
|
|
|
let publicResults = documentResults.filter(function(value){
|
|
return !value.querySelector('.privacy').innerHTML.includes("PRIVATE");
|
|
})
|
|
|
|
if (publicResults.length==0){
|
|
setStatus('No results matches "' + htmlEncode(_query) + '". Some private objects matches your search though.');
|
|
}
|
|
else{
|
|
setStatus(
|
|
'Search for "' + htmlEncode(_query) + '" yielded ' + publicResults.length + ' ' +
|
|
(publicResults.length === 1 ? 'result' : 'results') + '.');
|
|
}
|
|
}
|
|
|
|
function _isSearchInDocstringsEnabled() {
|
|
return searchInDocstringsCheckbox.checked;
|
|
}
|
|
|
|
function toggleSearchInDocstrings() {
|
|
if (searchInDocstringsCheckbox.checked){
|
|
searchInDocstringsButton.classList.add('label-success')
|
|
}
|
|
else{
|
|
if (searchInDocstringsButton.classList.contains('label-success')){
|
|
searchInDocstringsButton.classList.remove('label-success')
|
|
}
|
|
}
|
|
if (input.value.length>0){
|
|
launchSearch(true)
|
|
}
|
|
}
|
|
|
|
////// SETUP //////
|
|
|
|
// Attach launchSearch() to search text field update events.
|
|
|
|
input.oninput = (event) => {
|
|
setTimeout(() =>{
|
|
searchAsYouType();
|
|
}, 0);
|
|
};
|
|
input.onkeyup = (event) => {
|
|
if (event.key === 'Enter') {
|
|
launchSearch(true);
|
|
}
|
|
};
|
|
input.onfocus = (event) => {
|
|
// Ensure the search bar is set-up.
|
|
// Load fullsearchindex.json, searchindex.json and all-documents.html to have them in the cache asap.
|
|
isSearchReadyPromise = _getIsSearchReadyPromise();
|
|
}
|
|
document.onload = (event) => {
|
|
// Set-up search bar.
|
|
setTimeout(() =>{
|
|
isSearchReadyPromise = _getIsSearchReadyPromise();
|
|
}, 500);
|
|
}
|
|
|
|
// Close the dropdown if the user clicks on echap key
|
|
document.addEventListener('keyup', (evt) => {
|
|
evt = evt || window.event;
|
|
if (evt.key === "Escape" || evt.key === "Esc") {
|
|
hideResultContainer();
|
|
}
|
|
});
|
|
|
|
// Init search and help text.
|
|
// search box is not visible by default because
|
|
// we don't want to show it if the browser do not support JS.
|
|
window.addEventListener('load', (event) => {
|
|
document.getElementById('search-box-container').style.display = 'block';
|
|
document.getElementById('search-help-box').style.display = 'block';
|
|
hideResultContainer();
|
|
});
|
|
|
|
// This listener does 3 things.
|
|
window.addEventListener("click", (event) => {
|
|
if (event){
|
|
// 1. Hide the dropdown if the user clicks outside of it
|
|
if (!event.target.closest('#search-results-container')
|
|
&& !event.target.closest('#search-box')
|
|
&& !event.target.closest('#search-help-button')){
|
|
hideResultContainer();
|
|
return;
|
|
}
|
|
|
|
// 2. Show the dropdown if the user clicks inside the search box
|
|
if (event.target.closest('#search-box')){
|
|
if (input.value.length>0){
|
|
showResultContainer();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 3.Hide the dropdown if the user clicks on a link that brings them to the same page.
|
|
// This includes links in summaries.
|
|
link = event.target.closest('#search-results a')
|
|
if (link){
|
|
page_parts = document.location.pathname.split('/')
|
|
current_page = page_parts[page_parts.length-1]
|
|
href = link.getAttribute("href");
|
|
|
|
if (!href.startsWith(current_page)){
|
|
// The link points to something not in the same page, so don't hide the dropdown.
|
|
// The page will be reloaded anyway, but this ensure that if we go back, the dropdown will
|
|
// still be expanded.
|
|
return;
|
|
}
|
|
if (event.ctrlKey || event.shiftKey || event.metaKey){
|
|
// The link is openned in a new window/tab so don't hide the dropdown.
|
|
return;
|
|
}
|
|
hideResultContainer();
|
|
}
|
|
}
|
|
});
|