af3a143571e5aa064eab75c34f9444b35413b562 chmalee Tue Nov 30 15:28:15 2021 -0800 Add snippet support to trix searching. Required changing the wordPos from the first highest matching wordIndex to the wordIndex of the actual span. Have trixContextIndex create a second level index for fast retrieval of line offsets in original text file used by ixIxx. Create a simple UI for navigating hgFind search results. diff --git src/hg/js/searchExample.js src/hg/js/searchExample.js new file mode 100644 index 0000000..1a463ae --- /dev/null +++ src/hg/js/searchExample.js @@ -0,0 +1,754 @@ +var searchExample = (function() { + + // this object contains the categories we can facet/filter on and the + // search results + var uiState = {}; + var debugCartJson = true; + + var digitTest = /^\d+$/, + keyBreaker = /([^\[\]]+)|(\[\])/g, + plus = /\+/g, + paramTest = /([^?#]*)(#.*)?$/; + + function deparam(params) { + /* https://github.com/jupiterjs/jquerymx/blob/master/lang/string/deparam/deparam.js */ + if(! params || ! paramTest.test(params) ) { + return {}; + } + + var data = {}, + pairs = params.split('&'), + current; + + for (var i=0; i < pairs.length; i++){ + current = data; + var pair = pairs[i].split('='); + + // if we find foo=1+1=2 + if(pair.length !== 2) { + pair = [pair[0], pair.slice(1).join("=")]; + } + + var key = decodeURIComponent(pair[0].replace(plus, " ")), + value = decodeURIComponent(pair[1].replace(plus, " ")), + parts = key.match(keyBreaker); + + for ( var j = 0; j < parts.length - 1; j++ ) { + var part = parts[j]; + if (!current[part] ) { + //if what we are pointing to looks like an array + current[part] = digitTest.test(parts[j+1]) || parts[j+1] === "[]" ? [] : {}; + } + current = current[part]; + } + var lastPart = parts[parts.length - 1]; + if (lastPart === "[]"){ + current.push(value); + } else{ + current[lastPart] = value; + } + } + return data; + } + + function changeUrl(vars, oldVars) { + // Save the users search string to the url so web browser can easily + // cache search results into the browser history + // vars: object of new key: val pairs like CGI arguments + // oldVars: arguments we want to keep between calls + var myUrl = window.location.href; + myUrl = myUrl.replace('#',''); + var urlParts = myUrl.split("?"); + var baseUrl; + if (urlParts.length > 1) + baseUrl = urlParts[0]; + else + baseUrl = myUrl; + + var urlVars; + if (oldVars === undefined) { + var queryStr = urlParts[1]; + urlVars = deparam(queryStr); + } else { + urlVars = oldVars; + } + + for (var key in vars) { + var val = vars[key]; + if (val === null || val === "") { + if (key in urlVars) { + delete urlVars[key]; + } + } else { + urlVars[key] = val; + } + } + + var argStr = jQuery.param(urlVars); + argStr = argStr.replace(/%20/g, "+"); + + return {"baseUrl": baseUrl, "args": argStr, "urlVars": urlVars}; + } + + function saveHistory(obj, urlParts,replace) { + if (replace) { + history.replaceState(obj, "", urlParts.baseUrl + (urlParts.args.length !== 0 ? "?" + urlParts.args : "")); + } else { + history.pushState(obj, "", urlParts.baseUrl + (urlParts.args.length !== 0 ? "?" + urlParts.args : "")); + } + } + + // comparator function for sorting tracks, lowest priority wins, + // followed by short label + function compareTrack(trackA, trackB) { + priorityA = trackA.priority; + priorityB = trackB.priority; + // if both priorities are undefined or equal to each other, sort + // on shortlabel alphabetically + if (priorityA === priorityB) { + if (trackA.name < trackB.name) { + return -1; + } else if (trackA.name > trackB.name) { + return 1; + } else { + return 0; + } + } else { + if (priorityA === undefined) { + return 1; + } else if (priorityB === undefined) { + return -1; + } else if (priorityA < priorityB) { + return -1; + } else if (priorityA > priorityA) { + return 1; + } else { + return 0; + } + } + } + + function compareGroups(a, b) { + return uiState.trackGroups[a.name].priority - uiState.trackGroups[b.name].priority; + } + + function sortByTrackGroups(groupList) { + return groupList.sort(compareGroups); + } + + // Sort the nested track list structure such that within each group + // the leaves of the tree are sorted by priority + function sortTrackCategories(trackList) { + if (trackList.children !== undefined) { + trackList.children.sort(compareTrack); + for (var i = 0; i < trackList.children.length; i++) { + trackList.children[i] = sortTrackCategories(trackList.children[i]); + } + } + return trackList; + } + + function categoryComp(category) { + if (category.priority !== undefined) + return category.priority; + return 1000.0; + } + + function sortCategories(categList) { + return _.sortBy(categList, categoryComp); + } + + function filtersToJstree(categs) { + // Create a tree of filters, where on click of a leaf or group, show hide + // results from list + var filterData = []; + _.each(categs, function(categ) { + var trackGroups = uiState.trackGroups; + var groups = {}; + var parentsHash = {}; + var newCateg = {}; + _.assign(newCateg, categ); + newCateg.text = categ.longLabel; + if (categ.id !== undefined && categ.id === "trackData") { + newCateg.text = categ.label; + _.each(categ.tracks, function(track) { + var group = track.group; + if (uiState && uiState.positionMatches) { + inResults = _.find(uiState.positionMatches, function(match) { + return match.trackName === track.id; + }) !== undefined; + track.state = {selected: inResults}; + } + track.text = track.label; + track.li_attr = {title: track.description}; + if (track.visibility > 0) { + if (groups.visible) + groups.visible.children.push(track); + else { + groups.visible = {}; + groups.visible.id = "Visible Tracks"; + groups.visible.name = "Visible Tracks"; + groups.visible.text = "Visible Tracks"; + groups.visible.li_attr = {title: "Search for track items in all the currently visible searchable tracks"}; + groups.visible.children = [track]; + groups.visible.state = {opened: true, loaded: true}; + groups.visible.priority = 0.0; + } + } else { + var last = track; + var doNewComp = true; + if (track.parents) { + var tracksAndLabels = track.parents.split(','); + var l = tracksAndLabels.length; + for (var i = 0; i < l; i+=2) { + var parentTrack= tracksAndLabels[i]; + var parentLabel = tracksAndLabels[i+1]; + if (!(parentTrack in parentsHash)) { + parent = {}; + parent.id = parentTrack; + parent.text = parentLabel; + parent.children = [last]; + parent.li_attr = {title: "Search for track items in all of the searchable subtracks of the " + parentLabel + " track"}; + parentsHash[parentTrack] = parent; + last = parent; + doNewComp = true; + } else { + doNewComp = false; + parentsHash[parentTrack].children.push(last); + } + } + } + if (groups[group] !== undefined && doNewComp) { + groups[group].children.push(last); + } else if (doNewComp) { + groups[group] = {}; + groups[group].id = group; + groups[group].name = group; + groups[group].text = group; + groups[group].children = [last]; + if (trackGroups !== undefined && group in trackGroups) { + groups[group].priority = trackGroups[group].priority; + groups[group].text= trackGroups[group].label; + } else { + if (trackGroups === undefined) { + uiState.trackGroups = {}; + trackGroups = uiState.trackGroups; + } + trackGroups[group] = groups[group]; + } + } + } + }); + newCateg.children = []; + if ("visible" in groups) { + groups.visible.children = sortTrackCategories(groups.visible.children); + newCateg.children.push(groups.visible); + } + hiddenTrackChildren = []; + newCateg.children.push({id: "Currently Hidden Tracks", name: "Currently Hidden Tracks", text: "Currently Hidden Tracks", children: [], state: {opened: true, loaded: true}, li_attr: {title: "Search for track items in currently hidden tracks"}}); + _.each(groups, function(group) { + if (group.id !== "Visible Tracks") { + group.li_attr = {title: "Search for track items in the " + group.text + " set of tracks"}; + group.children = sortTrackCategories(group.children); + hiddenTrackChildren.push(group); + } + }); + hiddenTrackChildren = sortByTrackGroups(hiddenTrackChildren); + _.each(hiddenTrackChildren, function(group) { + newCateg.children.at(-1).children.push(group); + }); + filterData.push(newCateg); + } + }); + return filterData; + } + + function categsToJstree(categs) { + // We need to convert the categories to a jstree acceptable object + // which is an array of nodes, where each elem of the array + // is either a string, or an object with certain properties. + // Since categs is already an array, we can just add any necessary + // jstree attributes to the each categ + var categData = []; + _.each(categs, function(categ) { + categ.text = categ.longLabel; + if (categ.id !== undefined && categ.id === "trackData") { + categ.text = categ.label; + categ.state = {opened: true, loaded: true}; + categ.li_attr = {title: categ.description}; + } else { + categ.text = categ.label; + categ.state = {selected: categ.visibility > 0}; + categ.li_attr = {title: categ.description}; + } + categData.push(categ); + }); + uiState.categs = categData = sortCategories(categData); + return categData; + + // save the possible categories so we can load the page quickly upon back + // button or refresh + //localStorage.setItem('ucscGBSearchCategories', JSON.stringify(categs)); + } + + function makeCategoryTree(data) { + var parentDiv = $("#searchCategories"); + $.jstree.defaults.core.themes.icons = false; + $.jstree.defaults.core.themes.dots = true; + $.jstree.defaults.contextmenu.show_at_node = false; + parentDiv.jstree({ + 'plugins' : ['contextmenu', 'checkbox', 'state'], + 'core': { + 'data': data, + 'check_callback': true + }, + }); + parentDiv.css('height', "auto"); + } + + function showOrHideResults(event, node) { + if (node.state.checked) { + // show results + resultLi = $('#' + node.id + "Results")[0]; + if (resultLi === undefined) + alert(node); + resultLi.style = "display"; + } else { + // hide results + resultLi = $('#' + node.id + "Results")[0]; + resultLi.style = "display: none"; + } + } + + function makeFiltersTree(data) { + var parentDiv = $("#searchFilters"); + $.jstree.defaults.core.themes.icons = false; + $.jstree.defaults.core.themes.dots = true; + $.jstree.defaults.contextmenu.show_at_node = false; + parentDiv.jstree({ + 'plugins' : ['contextmenu', 'checkbox', 'state'], + 'core': { + 'data': data, + 'check_callback': true + }, + 'checkbox': { + 'tie_selection': false + } + }); + parentDiv.css('height', "auto"); + parentDiv.on('check_node.jstree uncheck_node.jstree', function(e, data) { + if ($("#searchResults")[0].children.length > 0) { + showOrHideResults(e,data.node); + } + }); + } + + function updateFilters(uiState) { + if (uiState.categs !== undefined) { + var categData = categsToJstree(uiState.categs); + var filtersData = filtersToJstree(uiState.categs); + makeCategoryTree(categData); + makeFiltersTree(filtersData); + } + } + + // Update the 'facet' counts with the number of search results returned per category + // Optionally, if a searchTime is present, update the time it took in milliseconds + // for this track/group to be searched + function updateCategoryCounts(categName, count, isParent, searchTime) { + var oldNode, newtext; + var oldCount = 0, oldSearchTime = 0; + theTree = $("#searchFilters").jstree(); + if (!categName.startsWith("trackDb")) + oldNode = theTree.get_node(categName); + else + oldNode = theTree.get_node("[id^=" + categName + "]"); + // If the user entered an hgvs term, we may have got a match to LRG or whatever track + // that isn't open yet, so open it now and check it so it's easier to find. + if (!isParent) { + theTree._open_to(oldNode); + theTree.check_node(oldNode); //.state.checked = true; + } + var oldCountSpan = $("[id='" + oldNode.id + "count'"); + var oldTimeSpan = $("[id='" + oldNode.id + "searchTime'"); + if (oldCountSpan.length) { + if (isParent) { + oldCount = parseInt(oldCountSpan[0].innerText.split(' ')[0].slice(1)); + } + } + if (oldTimeSpan.length) { + if (isParent) { + if (typeof searchTime !== 'undefined') { + oldSearchTime = parseInt(oldTimeSpan[0].innerText.split(' ')[1].slice(0,-2)); + } + } + } + newtext = ""; + newtext += " (" + (count + oldCount) + " results"; + if (typeof searchTime !== 'undefined') { + newtext += ", " + (searchTime + oldSearchTime) + "ms searchTime "; + } + newtext += ")"; // count or timing span + newtext += ""; // container span + if (typeof newtext !== 'undefined') + $("[id='" + oldNode.id + "extraInfo']").remove(); + $("[id='"+oldNode.id+"_anchor']").append(newtext); + + // bubble the counts up to any parents: + if (oldNode && oldNode.parent !== "#") + updateCategoryCounts(oldNode.parent, count, true, searchTime); + } + + function clearOldFacetCounts() { + $("[id*='extraInfo']").remove(); + } + + function printMatches(list, matches, title) { + var printCount = 0; + _.each(matches, function(match, printCount) { + var position = match.position.split(':'); + var url, matchTitle; + if (title === "helpDocs") { + url = position[0]; + matchTitle = position[1].replace(/_/g, " "); + } else if (title === "publicHubs") { + var hubUrl = position[0] + ":" + position[1]; + var dbName = position[2]; + var track = position[3]; + var hubShortLabel = position[4]; + var hubLongLabel = position[5]; + url = "hgTrackUi?hubUrl=" + hubUrl + "&g=" + track + "&db=" + dbName; + matchTitle = hubShortLabel; + } else if (title === "trackDb") { + var trackName = position[0]; + var shortLabel = position[1]; + var longLabel = position[2]; + url = "hgTrackUi?g=" + trackName; + matchTitle = shortLabel + " - " + longLabel; + } else { + url = "hgTracks?" + title + "=pack&position=" + match.position + "&hgFind.matches=" + match.hgFindMatches; + if (match.extraSel) + url += "&" + match.extraSel; + matchTitle = match.posName; + if (match.canonical === true) + matchTitle = "" + matchTitle + ""; + } + var newListObj; + if (printCount < 500) { + if (printCount + 1 > 10) { + newListObj = "
  • " + matchTitle + " - "; + } + printedPos = false; + if (!(["helpDocs", "publicHubs", "trackDb"].includes(title))) { + newListObj += match.position; + printedPos = true; + } + if (match.description) { + if (printedPos) {newListObj += " - ";} + newListObj += match.description; + } + newListObj += "
  • "; + list.innerHTML += newListObj; + printCount += 1; + } + }); + } + + function showMoreResults() { + var trackName = this.id.replace(/Results_.*/, ""); + var isHidden = $("." + trackName + "_hidden")[0].style.display === "none"; + _.each($("." + trackName + "_hidden"), function(hiddenLi) { + if (isHidden) { + hiddenLi.style = "display:"; + } else { + hiddenLi.style = "display: none"; + } + }); + if (isHidden) { + newText = this.nextSibling.innerHTML.replace(/Show/,"Hide"); + this.nextSibling.innerHTML = newText; + this.src = "../images/remove_sm.gif"; + } else { + newText = this.nextSibling.innerHTML.replace(/Hide/,"Show"); + this.nextSibling.innerHTML = newText; + this.src = "../images/add_sm.gif"; + } + } + + function collapseNode() { + var toCollapse = this.parentNode.childNodes[3]; + var isHidden = toCollapse.style.display === "none"; + if (isHidden) + { + toCollapse.style = 'display:'; + this.src = "../images/remove_sm.gif"; + } + else + { + toCollapse.style = 'display: none'; + this.src = "../images/add_sm.gif"; + } + } + + function updateSearchResults(uiState) { + var parentDiv = $("#searchResults"); + if (uiState && uiState.search !== undefined) { + $("#searchBarSearchString").val(uiState.search); + } else { + // back button all the way to the beginning + $("#searchBarSearchString").val(""); + } + if (uiState && uiState.positionMatches && uiState.positionMatches.length > 0) { + // clear the old search results if there were any: + parentDiv.empty(); + var newList = document.createElement("ul"); + var noUlStyle = document.createAttribute("class"); + noUlStyle.value = "ulNoStyle"; + newList.setAttributeNode(noUlStyle); + parentDiv.append(newList); + clearOldFacetCounts(); + var categoryCount = 0; + // Loop through categories of match (public hubs, help docs, a single track, ... + _.each(uiState.positionMatches, function(categ) { + var title = categ.name; + var searchDesc = categ.description; + var matches = categ.matches; + var numMatches = matches.length; + updateCategoryCounts(title, numMatches, false, categ.searchTime); + var newListObj = document.createElement("li"); + var idAttr = document.createAttribute("id"); + idAttr.value = title + 'Results'; + newListObj.setAttributeNode(idAttr); + var noLiStyle = document.createAttribute("class"); + noLiStyle.value = "liNoStyle"; + newListObj.setAttributeNode(noLiStyle); + newListObj.innerHTML += ""; + newListObj.innerHTML += ""; + newListObj.innerHTML += " Matches to " + searchDesc + ":"; + //printOneFullMatch(newList, matches[0], title, searchDesc); + // Now loop through each actual hit on this table and unpack onto list + var subList = document.createElement("ul"); + printMatches(subList, matches, title); + if (matches.length > 10) { + subList.innerHTML += "
  • "; + subList.innerHTML += ""; + subList.innerHTML += ""; + if (matches.length > 500) + subList.innerHTML += "
    Show 490 (out of " + (matches.length) + " total) more matches for " + searchDesc + "
  • "; + else + subList.innerHTML += "
    Show " + (matches.length - 10) + " more matches for " + searchDesc + "
    "; + } + newListObj.append(subList); + newList.append(newListObj); + + // make result list collapsible: + $('#'+idAttr.value+categoryCount+"_button").click(collapseNode); + $('#'+idAttr.value+"_" +categoryCount+"_showMoreButton").click(showMoreResults); + categoryCount += 1; + }); + } else if (uiState) { + // No results from match + var msg = "

    No results for: " + uiState.search + "

    "; + parentDiv.empty(); + parentDiv.html(msg); + clearOldFacetCounts(); + } else { + parentDiv.empty(); + } + } + + function checkJsonData(jsonData, callerName) { + // Return true if jsonData isn't empty and doesn't contain an error; + // otherwise complain on behalf of caller. + if (! jsonData) { + alert(callerName + ': empty response from server'); + } else if (jsonData.error) { + console.error(jsonData.error); + alert(callerName + ': error from server: ' + jsonData.error); + } else { + if (debugCartJson) { + console.log('from server:\n', jsonData); + } + return true; + } + return false; + } + + function updateStateAndPage(jsonData) { + // Update uiState with new values and update the page. + _.assign(uiState, jsonData); + updateFilters(uiState); + if (uiState.positionMatches !== undefined) { + updateSearchResults(uiState); + urlVars = {"search": uiState.search, "showSearchResults": ""}; + // changing the url allows the history to be associated to a specific url + var urlParts = changeUrl(urlVars); + saveHistory(uiState, urlParts); + } + } + + function handleRefreshState(jsonData) { + if (checkJsonData(jsonData, 'handleRefreshState')) { + updateStateAndPage(jsonData); + } + $("#spinner").remove(); + } + + function handleErrorState(jqXHR, textStatus) { + cart.defaultErrorCallback(jqXHR, textStatus); + $("#spinner").remove(); + } + + /* Recursive function to find the highest selected parent in the tree */ + function getParentCheckedNode(tree, node) { + if (node.parent === "#") { + if (node.state.selected) + return node; + } else if (node.parent === "Currently Hidden Tracks") { + hiddenTracksNode = tree.get_node(node.parent); + if (hiddenTracksNode.state.selected) { + topNode = tree.get_node(hiddenTracksNode.parent); + if (!topNode.state.selected) { + return node; + } else { + return getParentCheckedNode(tree, topNode); + } + } else { + return node; + } + } else { + var parent = tree.get_node(node.parent); + if ( parent.state.selected) { + return getParentCheckedNode(tree, parent); + } else { + return node; + } + } + } + + function getCheckedCategories() { + var ret = []; + var parents = {}; + theTree = $("#searchCategories").jstree(); + _.each($("#searchCategories").jstree().get_checked(full=true), function(categ) { + var parent = getParentCheckedNode(theTree, categ); + if (categ === parent && (parent.id.startsWith("Visible") || parent.id.startsWith("Currently Hidden"))) { + // goes to the next iteration of _.each(): + return; + } else { + if (parent !== categ && (parent.id.startsWith("Visible") || parent.id.startsWith("Currently Hidden"))) { + ret.push(categ.id); + } else if (!(parents[parent.id])) { + parents[parent.id] = true; + if (parent.id === "trackData") + ret.push("allTracks"); + else + ret.push(parent.id); + } + } + }); + return ret; + } + + function sendUserSearch() { + // User has clicked the search button, if they also entered a search + // term, fire off a search + cart.debug(debugCartJson); + var searchTerm = $("#searchBarSearchString").val(); + if (searchTerm !== undefined) { + // put up a loading image + $("#searchBarSearchButton").parent().append(""); + + // if the user entered a plain position string like chr1:blah-blah, just go to the old cgi/hgTracks + var canonMatch = searchTerm.match(canonicalRangeExp); + var gbrowserMatch = searchTerm.match(gbrowserRangeExp); + var lengthMatch = searchTerm.match(lengthRangeExp); + var bedMatch = searchTerm.match(bedRangeExp); + var sqlMatch = searchTerm.match(sqlRangeExp); + var singleMatch = searchTerm.match(singleBaseExp); + var positionMatch = canonMatch || gbrowserMatch || lengthMatch || bedMatch || sqlMatch || singleMatch; + if (positionMatch !== null) { + var prevCgi = uiState.prevCgi !== undefined ? uiState.prevCgi : "hgTracks"; + window.location.replace("../cgi-bin/" + prevCgi + "?position=" + searchTerm); + return; + } + + // change the url so the web browser history can function: + _.assign(uiState, {"search": searchTerm}); + var wantedFilters = getCheckedCategories(); + // save the selected categories to the users cart so if we navigate away and come back, the cgi + // can restore the old search results on the server side, we already save the results client + // side when staying on the page itself + //cart.send({ saveCategoriesToCart: {categs: wantedFilters}}); + //cart.flush(); + cart.send({ getSearchResults: {searchString: searchTerm, categs: wantedFilters}}, handleRefreshState, handleErrorState); + // always update the results when a search has happened + cart.flush(); + } + } + + function init() { + cart.setCgi('searchExample'); + cart.debug(debugCartJson); + // If a user clicks search before the page has finished loading + // start processing it now: + $("#searchBarSearchButton").click(sendUserSearch); + if (typeof cartJson !== "undefined") { + var urlParts = {}; + if (debugCartJson) { + console.log('from server:\n', cartJson); + } + if (typeof cartJson.search !== "undefined") { + urlParts = changeUrl({"search": cartJson.search, "showSearchResults": ""}); + } else { + urlParts = changeUrl({"showSearchResults": ""}); + cartJson.search = urlParts.urlVars.search; + } + _.assign(uiState,cartJson); + if (typeof cartJson.categs !== "undefined") { + var categData = categsToJstree(uiState.categs); + var filterData = filtersToJstree(uiState.categs); + makeCategoryTree(categData); + makeFiltersTree(categData); + } else { + cart.send({ getUiState: {} }, handleRefreshState); + cart.flush(); + } + $("#searchCategories").bind('ready.jstree', function(e, data) { + // wait for the jstree to finish loading before showing the results + $("#searchBarSearchString").val(uiState.search); + updateSearchResults(uiState); + saveHistory(cartJson, urlParts, true); + }); + } else { + // no cartJson object means we are coming to the page for the first time: + cart.send({ getUiState: {} }, handleRefreshState); + cart.flush(); + } + } + + return { init: init, + updateSearchResults: updateSearchResults + }; + +}()); + +$(document).ready(function() { + $('#searchBarSearchString').bind('keypress', function(e) { // binds listener to search button + if (e.which === 13) { // listens for return key + e.preventDefault(); // prevents return from also submitting whole form + if ($("#searchBarSearchString").val() !== undefined) { + $('#searchBarSearchButton').focus().click(); // clicks search button button + } + } + }); +}); + +// when a user reaches this page from the back button we can display our saved state +// instead of sending another network request +window.onpopstate = function(event) { + event.preventDefault(); + searchExample.updateSearchResults(event.state); +};