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" | 
