diff options
| -rw-r--r-- | background_scripts/completion.coffee | 57 | ||||
| -rw-r--r-- | lib/utils.coffee | 20 | ||||
| -rw-r--r-- | pages/vomnibar.coffee | 54 | ||||
| -rw-r--r-- | tests/unit_tests/utils_test.coffee | 10 |
4 files changed, 99 insertions, 42 deletions
diff --git a/background_scripts/completion.coffee b/background_scripts/completion.coffee index 91fb85e1..5fc98b88 100644 --- a/background_scripts/completion.coffee +++ b/background_scripts/completion.coffee @@ -22,18 +22,18 @@ class Suggestion @title = "" # Extra data which will be available to the relevancy function. @relevancyData = null - # If @autoSelect is truthy, then this suggestion is automatically pre-selected in the vomnibar. There may - # be at most one such suggestion. + # If @autoSelect is truthy, then this suggestion is automatically pre-selected in the vomnibar. This only + # affects the suggestion in slot 0 in the vomnibar. @autoSelect = false - # If truthy (and @autoSelect is truthy too), then this suggestion is always pre-selected when the query - # changes. There may be at most one such suggestion. - @forceAutoSelect = false - # If @highlightTerms is true, then we highlight matched terms in the title and URL. + # If @highlightTerms is true, then we highlight matched terms in the title and URL. Otherwise we don't. @highlightTerms = true - # If @insertText is a string, then the indicated text is inserted into the vomnibar input when the - # suggestion is selected. + # @insertText is text to insert into the vomnibar input when the suggestion is selected. @insertText = null + # Other options set by individual completers include: + # - tabId (TabCompleter) + # - isSearchSuggestion, customSearchMode (SearchEngineCompleter) + extend this, @options computeRelevancy: -> @@ -230,13 +230,16 @@ class BookmarkCompleter RankingUtils.wordRelevancy(suggestion.queryTerms, suggestion.url, suggestion.title) class HistoryCompleter - filter: ({ queryTerms }, onComplete) -> + filter: ({ queryTerms, seenTabToOpenCompletionList }, onComplete) -> @currentSearch = { queryTerms: @queryTerms, onComplete: @onComplete } results = [] HistoryCache.use (history) => results = if queryTerms.length > 0 history.filter (entry) -> RankingUtils.matches(queryTerms, entry.url, entry.title) + else if seenTabToOpenCompletionList + # <Tab> opens the completion list, even without a query. + history else [] onComplete results.map (entry) => @@ -251,6 +254,8 @@ class HistoryCompleter computeRelevancy: (suggestion) -> historyEntry = suggestion.relevancyData recencyScore = RankingUtils.recencyScore(historyEntry.lastVisitTime) + # If there are no query terms, then relevancy is based on recency alone. + return recencyScore if suggestion.queryTerms.length == 0 wordRelevancy = RankingUtils.wordRelevancy(suggestion.queryTerms, suggestion.url, suggestion.title) # Average out the word score and the recency. Recency has the ability to pull the score up, but not down. (wordRelevancy + Math.max recencyScore, wordRelevancy) / 2 @@ -397,9 +402,10 @@ class SearchEngineCompleter # This looks up the custom search engine and, if one is found, notes it and removes its keyword from the # query terms. - triageRequest: (request) -> + preprocessRequest: (request) -> @searchEngines.use (engines) => { queryTerms, query } = request + request.searchEngines = engines keyword = queryTerms[0] # Note. For a keyword "w", we match "w search terms" and "w ", but not "w" on its own. if keyword and engines[keyword] and (1 < queryTerms.length or /\s$/.test query) @@ -468,7 +474,7 @@ class SearchEngineCompleter # We only accept suggestions: # - from this completer, or # - from other completers, but then only if their URL matches this search engine and matches this - # query (that is only if their URL could have been generated by this search engine). + # query (that is only if their URL could have been generated by this search engine). suggestions.filter (suggestion) -> suggestion.type == description or # This is a suggestion for the same search engine. @@ -483,8 +489,8 @@ class SearchEngineCompleter title: queryTerms.join " " relevancy: 1 autoSelect: custom - forceAutoSelect: custom highlightTerms: not haveCompletionEngine + isSearchSuggestion: true mkSuggestion = (suggestion) => new Suggestion @@ -542,6 +548,22 @@ class SearchEngineCompleter Suggestion.boostRelevancyScore 0.5, relevancyData * RankingUtils.wordRelevancy queryTerms, title, title + postProcessSuggestions: (request, suggestions) -> + return unless request.searchEngines + engines = (engine for _, engine of request.searchEngines) + engines.sort (a,b) -> b.searchUrl.length - a.searchUrl.length + engines.push keyword: null, description: "search", searchUrl: Settings.get "searchUrl" + for suggestion in suggestions + unless suggestion.isSearchSuggestion or suggestion.insertText + for engine in engines + if suggestion.insertText = Utils.extractQuery engine.searchUrl, suggestion.url + # suggestion.customSearchMode informs the vomnibar that, if the users edits the text from this + # suggestion, then custom search-engine mode should be activated. + suggestion.customSearchMode = engine.keyword + suggestion.title = suggestion.insertText + suggestion.type = engine.description ? "custom search" + break + # A completer which calls filter() on many completers, aggregates the results, ranks them, and returns the top # 10. All queries from the vomnibar come through a multi completer. class MultiCompleter @@ -559,7 +581,7 @@ class MultiCompleter # Provide each completer with an opportunity to see (and possibly alter) the request before it is # launched. - completer.triageRequest? request for completer in @completers + completer.preprocessRequest? request for completer in @completers RegexpCache.clear() { queryTerms } = request @@ -585,7 +607,7 @@ class MultiCompleter # Post results, unless there are none and we will be running a continuation. This avoids # collapsing the vomnibar briefly before expanding it again, which looks ugly. unless suggestions.length == 0 and shouldRunContinuations - suggestions = @prepareSuggestions queryTerms, suggestions + suggestions = @prepareSuggestions request, queryTerms, suggestions onComplete results: suggestions mayCacheResults: continuations.length == 0 @@ -602,7 +624,7 @@ class MultiCompleter jobs.onReady => suggestions = filter suggestions for filter in filters - suggestions = @prepareSuggestions queryTerms, suggestions + suggestions = @prepareSuggestions request, queryTerms, suggestions # We post these results even if a new query has started. The vomnibar will not display them # (because they're arriving too late), but it will cache them. onComplete @@ -614,7 +636,7 @@ class MultiCompleter if @mostRecentQuery @filter @mostRecentQuery... - prepareSuggestions: (queryTerms, suggestions) -> + prepareSuggestions: (request, queryTerms, suggestions) -> # Compute suggestion relevancies and sort. suggestion.computeRelevancy queryTerms for suggestion in suggestions suggestions.sort (a, b) -> b.relevancy - a.relevancy @@ -629,6 +651,9 @@ class MultiCompleter break if count++ == @maxResults seenUrls[url] = suggestion + # Give each completer the opportunity to tweak the suggestions. + completer.postProcessSuggestions? request, suggestions for completer in @completers + # Generate HTML for the remaining suggestions and return them. suggestion.generateHtml() for suggestion in suggestions suggestions diff --git a/lib/utils.coffee b/lib/utils.coffee index 9a5661de..65e26b7a 100644 --- a/lib/utils.coffee +++ b/lib/utils.coffee @@ -114,6 +114,26 @@ Utils = searchUrl += "%s" unless 0 <= searchUrl.indexOf "%s" searchUrl.replace /%s/g, @createSearchQuery query + # Extract a query from url if it appears to be a URL created from the given search URL. + # For example, map "https://www.google.ie/search?q=star+wars&foo&bar" to "star wars". + extractQuery: do => + queryTerminator = new RegExp "[?&#/]" + httpProtocolRegexp = new RegExp "^https?://" + (searchUrl, url) -> + url = url.replace httpProtocolRegexp + searchUrl = searchUrl.replace httpProtocolRegexp + [ searchUrl, suffixTerms... ] = searchUrl.split "%s" + # We require the URL to start with the search URL. + return null unless url.startsWith searchUrl + # We require any remaining terms in the search URL to also be present in the URL. + for suffix in suffixTerms + return null unless 0 <= url.indexOf suffix + # We use try/catch because decodeURIComponent can throw an exception. + try + url[searchUrl.length..].split(queryTerminator)[0].split("+").map(decodeURIComponent).join " " + catch + null + # Converts :string into a Google search if it's not already a URL. We don't bother with escaping characters # as Chrome will do that for us. convertToUrl: (string) -> diff --git a/pages/vomnibar.coffee b/pages/vomnibar.coffee index a20ae7f3..fd7fd3cc 100644 --- a/pages/vomnibar.coffee +++ b/pages/vomnibar.coffee @@ -67,22 +67,13 @@ class VomnibarUI @completionList.style.display = "" @input.value = "" @completions = [] - @previousAutoSelect = null @previousInputValue = null @customSearchMode = null @selection = @initialSelectionValue @keywords = [] + @seenTabToOpenCompletionList = false updateSelection: -> - # We retain global state here (previousAutoSelect) to tell if a search item (for which autoSelect is set) - # has just appeared or disappeared. If that happens, we set @selection to 0 or -1. - if 0 < @completions.length - @selection = 0 if @completions[0].autoSelect and not @previousAutoSelect - @selection = -1 if @previousAutoSelect and not @completions[0].autoSelect - @previousAutoSelect = @completions[0].autoSelect - else - @previousAutoSelect = null - # For custom search engines, we suppress the leading term (e.g. the "w" of "w query terms") within the # vomnibar input. if @lastReponse.customSearchMode and not @customSearchMode? @@ -95,7 +86,7 @@ class VomnibarUI # typing. if 0 <= @selection and @completions[@selection].insertText? @previousInputValue ?= @input.value - @input.value = @completions[@selection].insertText + (if @selection == 0 then "" else " ") + @input.value = @completions[@selection].insertText else if @previousInputValue? @input.value = @previousInputValue @previousInputValue = null @@ -135,9 +126,13 @@ class VomnibarUI if (action == "dismiss") @hide() else if action in [ "tab", "down" ] - @selection += 1 - @selection = @initialSelectionValue if @selection == @completions.length - @updateSelection() + if @input.value.trim().length == 0 and action == "tab" and not @seenTabToOpenCompletionList + @seenTabToOpenCompletionList = true + @update true + else + @selection += 1 + @selection = @initialSelectionValue if @selection == @completions.length + @updateSelection() else if (action == "up") @selection -= 1 @selection = @completions.length - 1 if @selection < @initialSelectionValue @@ -160,12 +155,16 @@ class VomnibarUI completion = @completions[@selection] @hide -> completion.performAction openInNewTab else if action == "delete" - if @customSearchMode? and @input.value.length == 0 + inputIsEmpty = @input.value.length == 0 + if inputIsEmpty and @customSearchMode? # Normally, with custom search engines, the keyword (e,g, the "w" of "w query terms") is suppressed. # If the input is empty, then reinstate the keyword (the "w"). @input.value = @customSearchMode @customSearchMode = null - @updateCompletions() + @update true + else if inputIsEmpty and @seenTabToOpenCompletionList + @seenTabToOpenCompletionList = false + @update true else return true # Do not suppress event. @@ -182,39 +181,43 @@ class VomnibarUI updateCompletions: (callback = null) -> @completer.filter query: @getInputValueAsQuery() + seenTabToOpenCompletionList: @seenTabToOpenCompletionList callback: (@lastReponse) => { results } = @lastReponse @completions = results + @selection = if @completions[0]?.autoSelect then 0 else @initialSelectionValue # Update completion list with the new suggestions. @completionList.innerHTML = @completions.map((completion) -> "<li>#{completion.html}</li>").join("") @completionList.style.display = if @completions.length > 0 then "block" else "" @selection = Math.min @completions.length - 1, Math.max @initialSelectionValue, @selection - @previousAutoSelect = null if @completions[0]?.autoSelect and @completions[0]?.forceAutoSelect @updateSelection() callback?() - updateOnInput: => + onInput: => + @seenTabToOpenCompletionList = false @completer.cancel() - # If the user types, then don't reset any previous text, and restart auto select. + if 0 <= @selection and @completions[@selection].customSearchMode and not @customSearchMode + @customSearchMode = @completions[@selection].customSearchMode + updateSynchronously = true + # If the user types, then don't reset any previous text, and reset the selection. if @previousInputValue? @previousInputValue = null - @previousAutoSelect = null @selection = -1 - @update false + @update updateSynchronously clearUpdateTimer: -> if @updateTimer? window.clearTimeout @updateTimer @updateTimer = null - isCustomSearch: -> + shouldActivateCustomSearchMode: -> queryTerms = @input.value.ltrim().split /\s+/ - 1 < queryTerms.length and queryTerms[0] in @keywords + 1 < queryTerms.length and queryTerms[0] in @keywords and not @customSearchMode update: (updateSynchronously = false, callback = null) => # If the query text becomes a custom search (the user enters a search keyword), then we need to force a # synchronous update (so that the state is updated immediately). - updateSynchronously ||= @isCustomSearch() and not @customSearchMode? + updateSynchronously ||= @shouldActivateCustomSearchMode() if updateSynchronously @clearUpdateTimer() @updateCompletions callback @@ -231,7 +234,7 @@ class VomnibarUI @box = document.getElementById("vomnibar") @input = @box.querySelector("input") - @input.addEventListener "input", @updateOnInput + @input.addEventListener "input", @onInput @input.addEventListener "keydown", @onKeydown @completionList = @box.querySelector("ul") @completionList.style.display = "" @@ -286,7 +289,6 @@ class BackgroundCompleter queryTerms: query.trim().split(/\s+/).filter (s) -> 0 < s.length # We don't send these keys. callback: null - mayUseVomnibarCache: null reset: -> @keywords = [] diff --git a/tests/unit_tests/utils_test.coffee b/tests/unit_tests/utils_test.coffee index d1444af2..bfe066c3 100644 --- a/tests/unit_tests/utils_test.coffee +++ b/tests/unit_tests/utils_test.coffee @@ -49,6 +49,16 @@ context "convertToUrl", assert.equal "https://www.google.com/search?q=go+ogle.com", Utils.convertToUrl("go ogle.com") assert.equal "https://www.google.com/search?q=%40twitter", Utils.convertToUrl("@twitter") +context "extractQuery", + should "extract queries from search URLs", -> + assert.equal "bbc sport 1", Utils.extractQuery "https://www.google.ie/search?q=%s", "https://www.google.ie/search?q=bbc+sport+1" + assert.equal "bbc sport 2", Utils.extractQuery "http://www.google.ie/search?q=%s", "https://www.google.ie/search?q=bbc+sport+2" + assert.equal "bbc sport 3", Utils.extractQuery "https://www.google.ie/search?q=%s", "http://www.google.ie/search?q=bbc+sport+3" + assert.equal "bbc sport 4", Utils.extractQuery "https://www.google.ie/search?q=%s", "http://www.google.ie/search?q=bbc+sport+4&blah" + + should "extract not queries from incorrect search URLs", -> + assert.isFalse Utils.extractQuery "https://www.google.ie/search?q=%s&foo=bar", "https://www.google.ie/search?q=bbc+sport" + context "hasChromePrefix", should "detect chrome prefixes of URLs", -> assert.isTrue Utils.hasChromePrefix "about:foobar" |
