diff options
| author | Stephen Blott | 2015-05-11 14:55:00 +0100 | 
|---|---|---|
| committer | Stephen Blott | 2015-05-11 14:55:00 +0100 | 
| commit | 104dc7ff2c88c7df9760c6ca35991d9c160bbb35 (patch) | |
| tree | ecf9af8cfc32756308f39e12d4c10df43304e143 | |
| parent | 3503b5a510416e04449272a461e6765b7e7cd3f7 (diff) | |
| parent | 4ba12991a277d193969e87706facdba12fdee4d0 (diff) | |
| download | vimium-104dc7ff2c88c7df9760c6ca35991d9c160bbb35.tar.bz2 | |
Merge branch 'search-engine-completion-v5'
| -rw-r--r-- | background_scripts/completion.coffee | 419 | ||||
| -rw-r--r-- | background_scripts/completion_engines.coffee | 120 | ||||
| -rw-r--r-- | background_scripts/completion_search.coffee | 133 | ||||
| -rw-r--r-- | background_scripts/main.coffee | 50 | ||||
| -rw-r--r-- | background_scripts/settings.coffee | 23 | ||||
| -rw-r--r-- | lib/utils.coffee | 122 | ||||
| -rw-r--r-- | manifest.json | 2 | ||||
| -rw-r--r-- | pages/options.coffee | 1 | ||||
| -rw-r--r-- | pages/options.css | 5 | ||||
| -rw-r--r-- | pages/options.html | 27 | ||||
| -rw-r--r-- | pages/vomnibar.coffee | 419 | ||||
| -rw-r--r-- | pages/vomnibar.css | 11 | ||||
| -rw-r--r-- | pages/vomnibar.html | 2 | ||||
| -rw-r--r-- | tests/dom_tests/vomnibar_test.coffee | 2 | ||||
| -rw-r--r-- | tests/unit_tests/completion_test.coffee | 52 | ||||
| -rw-r--r-- | tests/unit_tests/settings_test.coffee | 10 | 
16 files changed, 1080 insertions, 318 deletions
| diff --git a/background_scripts/completion.coffee b/background_scripts/completion.coffee index 6a1c0d30..23526f85 100644 --- a/background_scripts/completion.coffee +++ b/background_scripts/completion.coffee @@ -5,26 +5,41 @@  # The Vomnibox frontend script makes a "filterCompleter" request to the background page, which in turn calls  # filter() on each these completers.  # -# A completer is a class which has two functions: +# A completer is a class which has three functions:  #  - filter(query, onComplete): "query" will be whatever the user typed into the Vomnibox.  #  - refresh(): (optional) refreshes the completer's data source (e.g. refetches the list of bookmarks). - -# A Suggestion is a bookmark or history entry which matches the current query. -# It also has an attached "computeRelevancyFunction" which determines how well this item matches the given -# query terms. +#  - cancel(): (optional) cancels any pending, cancelable action.  class Suggestion -  showRelevancy: false # Set this to true to render relevancy when debugging the ranking scores. - -  # - type: one of [bookmark, history, tab]. -  # - computeRelevancyFunction: a function which takes a Suggestion and returns a relevancy score -  #   between [0, 1] -  # - extraRelevancyData: data (like the History item itself) which may be used by the relevancy function. -  constructor: (@queryTerms, @type, @url, @title, @computeRelevancyFunction, @extraRelevancyData) -> -    @title ||= "" -    # When @autoSelect is truthy, the suggestion is automatically pre-selected in the vomnibar. +  showRelevancy: true # Set this to true to render relevancy when debugging the ranking scores. + +  constructor: (@options) -> +    # Required options. +    @queryTerms = null +    @type = null +    @url = null +    @relevancyFunction = null +    # Other options. +    @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.      @autoSelect = false - -  computeRelevancy: -> @relevancy = @computeRelevancyFunction(this) +    # 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. +    @highlightTerms = true +    # If @insertText is a string, then the indicated text is inserted into the vomnibar input when the +    # suggestion is selected. +    @insertText = null + +    extend this, @options + +  computeRelevancy: -> +    # We assume that, once the relevancy has been set, it won't change.  Completers must set either @relevancy +    # or @relevancyFunction. +    @relevancy ?= @relevancyFunction this    generateHtml: ->      return @html if @html @@ -34,10 +49,10 @@ class Suggestion        """        <div class="vimiumReset vomnibarTopHalf">           <span class="vimiumReset vomnibarSource">#{@type}</span> -         <span class="vimiumReset vomnibarTitle">#{@highlightTerms(Utils.escapeHtml(@title))}</span> +         <span class="vimiumReset vomnibarTitle">#{@highlightQueryTerms Utils.escapeHtml @title}</span>         </div>         <div class="vimiumReset vomnibarBottomHalf"> -        <span class="vimiumReset vomnibarUrl">#{@shortenUrl(@highlightTerms(Utils.escapeHtml(@url)))}</span> +        <span class="vimiumReset vomnibarUrl">#{@shortenUrl @highlightQueryTerms Utils.escapeHtml @url}</span>          #{relevancyHtml}        </div>        """ @@ -48,6 +63,11 @@ class Suggestion      a.href = url      a.protocol + "//" + a.hostname +  getHostname: (url) -> +    a = document.createElement 'a' +    a.href = url +    a.hostname +    shortenUrl: (url) -> @stripTrailingSlash(url).replace(/^https?:\/\//, "")    stripTrailingSlash: (url) -> @@ -77,7 +97,8 @@ class Suggestion        textPosition += matchedText.length    # Wraps each occurence of the query terms in the given string in a <span>. -  highlightTerms: (string) -> +  highlightQueryTerms: (string) -> +    return string unless @highlightTerms      ranges = []      escapedTerms = @queryTerms.map (term) -> Utils.escapeHtml(term)      for term in escapedTerms @@ -115,7 +136,7 @@ class BookmarkCompleter    # These bookmarks are loaded asynchronously when refresh() is called.    bookmarks: null -  filter: (@queryTerms, @onComplete) -> +  filter: ({ @queryTerms }, @onComplete) ->      @currentSearch = { queryTerms: @queryTerms, onComplete: @onComplete }      @performSearch() if @bookmarks @@ -133,11 +154,15 @@ class BookmarkCompleter        else          []      suggestions = results.map (bookmark) => -      suggestionTitle = if usePathAndTitle then bookmark.pathAndTitle else bookmark.title -      new Suggestion(@currentSearch.queryTerms, "bookmark", bookmark.url, suggestionTitle, @computeRelevancy) +      new Suggestion +        queryTerms: @currentSearch.queryTerms +        type: "bookmark" +        url: bookmark.url +        title: if usePathAndTitle then bookmark.pathAndTitle else bookmark.title +        relevancyFunction: @computeRelevancy      onComplete = @currentSearch.onComplete      @currentSearch = null -    onComplete(suggestions) +    onComplete suggestions    refresh: ->      @bookmarks = null @@ -172,7 +197,7 @@ class BookmarkCompleter      RankingUtils.wordRelevancy(suggestion.queryTerms, suggestion.url, suggestion.title)  class HistoryCompleter -  filter: (queryTerms, onComplete) -> +  filter: ({ queryTerms }, onComplete) ->      @currentSearch = { queryTerms: @queryTerms, onComplete: @onComplete }      results = []      HistoryCache.use (history) => @@ -181,18 +206,21 @@ class HistoryCompleter            history.filter (entry) -> RankingUtils.matches(queryTerms, entry.url, entry.title)          else            [] -      suggestions = results.map (entry) => -        new Suggestion(queryTerms, "history", entry.url, entry.title, @computeRelevancy, entry) -      onComplete(suggestions) +      onComplete results.map (entry) => +        new Suggestion +          queryTerms: queryTerms +          type: "history" +          url: entry.url +          title: entry.title +          relevancyFunction: @computeRelevancy +          relevancyData: entry    computeRelevancy: (suggestion) -> -    historyEntry = suggestion.extraRelevancyData +    historyEntry = suggestion.relevancyData      recencyScore = RankingUtils.recencyScore(historyEntry.lastVisitTime)      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. -    score = (wordRelevancy + Math.max(recencyScore, wordRelevancy)) / 2 - -  refresh: -> +    (wordRelevancy + Math.max recencyScore, wordRelevancy) / 2  # The domain completer is designed to match a single-word query which looks like it is a domain. This supports  # the user experience where they quickly type a partial domain, hit tab -> enter, and expect to arrive there. @@ -203,8 +231,8 @@ class DomainCompleter    #     If `referenceCount` goes to zero, the domain entry can and should be deleted.    domains: null -  filter: (queryTerms, onComplete) -> -    return onComplete([]) unless queryTerms.length == 1 +  filter: ({ queryTerms, query }, onComplete) -> +    return onComplete [] unless queryTerms.length == 1 and not /\s$/.test query      if @domains        @performSearch(queryTerms, onComplete)      else @@ -212,20 +240,24 @@ class DomainCompleter    performSearch: (queryTerms, onComplete) ->      query = queryTerms[0] -    domainCandidates = (domain for domain of @domains when domain.indexOf(query) >= 0) -    domains = @sortDomainsByRelevancy(queryTerms, domainCandidates) -    return onComplete([]) if domains.length == 0 -    topDomain = domains[0][0] -    onComplete([new Suggestion(queryTerms, "domain", topDomain, null, @computeRelevancy)]) +    domains = (domain for domain of @domains when 0 <= domain.indexOf query) +    domains = @sortDomainsByRelevancy queryTerms, domains +    onComplete [ +      new Suggestion +        queryTerms: queryTerms +        type: "domain" +        url: domains[0]?[0] ? "" # This is the URL or an empty string, but not null. +        relevancy: 1 +      ].filter (s) -> 0 < s.url.length    # Returns a list of domains of the form: [ [domain, relevancy], ... ]    sortDomainsByRelevancy: (queryTerms, domainCandidates) -> -    results = [] -    for domain in domainCandidates -      recencyScore = RankingUtils.recencyScore(@domains[domain].entry.lastVisitTime || 0) -      wordRelevancy = RankingUtils.wordRelevancy(queryTerms, domain, null) -      score = (wordRelevancy + Math.max(recencyScore, wordRelevancy)) / 2 -      results.push([domain, score]) +    results = +      for domain in domainCandidates +        recencyScore = RankingUtils.recencyScore(@domains[domain].entry.lastVisitTime || 0) +        wordRelevancy = RankingUtils.wordRelevancy queryTerms, domain, null +        score = (wordRelevancy + Math.max(recencyScore, wordRelevancy)) / 2 +        [domain, score]      results.sort (a, b) -> b[1] - a[1]      results @@ -258,9 +290,6 @@ class DomainCompleter    parseDomainAndScheme: (url) ->        Utils.hasFullUrlPrefix(url) and not Utils.hasChromePrefix(url) and url.split("/",3).join "/" -  # Suggestions from the Domain completer have the maximum relevancy. They should be shown first in the list. -  computeRelevancy: -> 1 -  # TabRecency associates a logical timestamp with each tab id.  These are used to provide an initial  # recency-based ordering in the tabs vomnibar (which allows jumping quickly between recently-visited tabs).  class TabRecency @@ -304,16 +333,20 @@ tabRecency = new TabRecency()  # Searches through all open tabs, matching on title and URL.  class TabCompleter -  filter: (queryTerms, onComplete) -> +  filter: ({ queryTerms }, onComplete) ->      # NOTE(philc): We search all tabs, not just those in the current window. I'm not sure if this is the      # correct UX.      chrome.tabs.query {}, (tabs) =>        results = tabs.filter (tab) -> RankingUtils.matches(queryTerms, tab.url, tab.title)        suggestions = results.map (tab) => -        suggestion = new Suggestion(queryTerms, "tab", tab.url, tab.title, @computeRelevancy) -        suggestion.tabId = tab.id -        suggestion -      onComplete(suggestions) +        new Suggestion +          queryTerms: queryTerms +          type: "tab" +          url: tab.url +          title: tab.title +          relevancyFunction: @computeRelevancy +          tabId: tab.id +      onComplete suggestions    computeRelevancy: (suggestion) ->      if suggestion.queryTerms.length @@ -321,89 +354,226 @@ class TabCompleter      else        tabRecency.recencyScore(suggestion.tabId) -# A completer which will return your search engines  class SearchEngineCompleter -  searchEngines: {} - -  filter: (queryTerms, onComplete) -> -    {url: url, description: description} = @getSearchEngineMatches queryTerms +  @debug: false +  searchEngines: null + +  cancel: -> +    CompletionSearch.cancel() + +  # This looks up the custom search engine and, if one is found, then notes it and removes its keyword from +  # the query terms.  It also sets request.completers to indicate that only this completer should run. +  triageRequest: (request) -> +    @searchEngines.use (engines) => +      { queryTerms, query } = request +      keyword = queryTerms[0] +      if keyword and engines[keyword] and (1 < queryTerms.length or /\s$/.test query) +        request.completers = [ this ] +        extend request, +          queryTerms: queryTerms[1..] +          keyword: keyword +          engine: engines[keyword] + +  refresh: (port) -> +    # Parse the search-engine configuration. +    @searchEngines = new AsyncDataFetcher (callback) -> +      engines = {} +      for line in Settings.get("searchEngines").split "\n" +        line = line.trim() +        continue if /^[#"]/.test line +        tokens = line.split /\s+/ +        continue unless 2 <= tokens.length +        keyword = tokens[0].split(":")[0] +        url = tokens[1] +        description = tokens[2..].join(" ") || "search (#{keyword})" +        continue unless Utils.hasFullUrlPrefix url +        engines[keyword] = +          keyword: keyword +          searchUrl: url +          description: description + +      callback engines + +      # Let the front-end vomnibar know the search-engine keywords. +      port.postMessage +        handler: "keywords" +        keywords: key for own key of engines + +  filter: ({ queryTerms, query, engine }, onComplete) ->      suggestions = [] -    if url -      url = url.replace(/%s/g, Utils.createSearchQuery queryTerms[1..]) -      if description -        type = description -        query = queryTerms[1..].join " " -      else -        type = "search" -        query = queryTerms[0] + ": " + queryTerms[1..].join(" ") -      suggestion = new Suggestion(queryTerms, type, url, query, @computeRelevancy) -      suggestion.autoSelect = true -      suggestions.push(suggestion) -    onComplete(suggestions) - -  computeRelevancy: -> 1 -  refresh: -> -    @searchEngines = SearchEngineCompleter.getSearchEngines() - -  getSearchEngineMatches: (queryTerms) -> -    (1 < queryTerms.length and @searchEngines[queryTerms[0]]) or {} - -  # Static data and methods for parsing the configured search engines.  We keep a cache of the search-engine -  # mapping in @searchEnginesMap. -  @searchEnginesMap: null - -  # Parse the custom search engines setting and cache it in SearchEngineCompleter.searchEnginesMap. -  @parseSearchEngines: (searchEnginesText) -> -    searchEnginesMap = SearchEngineCompleter.searchEnginesMap = {} -    for line in searchEnginesText.split /\n/ -      tokens = line.trim().split /\s+/ -      continue if tokens.length < 2 or tokens[0].startsWith('"') or tokens[0].startsWith("#") -      keywords = tokens[0].split ":" -      continue unless keywords.length == 2 and not keywords[1] # So, like: [ "w", "" ]. -      searchEnginesMap[keywords[0]] = -        url: tokens[1] -        description: tokens[2..].join(" ") - -  # Fetch the search-engine map, building it if necessary. -  @getSearchEngines: -> -    unless SearchEngineCompleter.searchEnginesMap? -      SearchEngineCompleter.parseSearchEngines Settings.get "searchEngines" -    SearchEngineCompleter.searchEnginesMap +    { custom, searchUrl, description } = +      if engine +        { keyword, searchUrl, description } = engine +        custom: true +        searchUrl: searchUrl +        description: description +      else +        custom: false +        searchUrl: Settings.get "searchUrl" +        description: "search" + +    return onComplete [] unless custom or 0 < queryTerms.length + +    query = queryTerms.join " " +    factor = Settings.get "omniSearchWeight" +    haveCompletionEngine = CompletionSearch.haveCompletionEngine searchUrl +    haveCompletionEngine = false unless 0.0 < factor + +    # Relevancy: +    #   - Relevancy does not depend upon the actual suggestion (so, it does not depend upon word +    #     relevancy, say).  We assume that the completion engine has already factored that in.  Also, +    #     completion engines often handle spelling mistakes, in which case we wouldn't find the query terms +    #     in the suggestion anyway. +    #   - Scores are weighted such that they retain the order provided by the completion engine. +    #   - The relavancy is higher if the query term is longer.  The idea is that search suggestions are more +    #     likely to be relevant if, after typing some number of characters, the user hasn't yet found +    #     a useful suggestion from another completer. +    # +    characterCount = query.length - queryTerms.length + 1 +    relavancy = factor * (Math.min(characterCount, 10.0)/10.0) + +    # This distinguishes two very different kinds of vomnibar baviours, the newer bahviour (true) and the +    # legacy behavior (false).  We retain the latter for the default search engine, and for custom search +    # engines for which we do not have a completion engine.  By "exclusive vomnibar", we mean suggestions +    # from other completers are suppressed (so the vomnibar "exclusively" uses suggestions from this search +    # engine). +    useExclusiveVomnibar = custom and haveCompletionEngine +    filter = if useExclusiveVomnibar then (suggestion) -> suggestion.type == description else null + +    # For custom search engines, we add a single, top-ranked entry for the unmodified query.  This +    # suggestion always appears at the top of the list. +    if custom +      suggestions.push new Suggestion +        queryTerms: queryTerms +        type: description +        url: Utils.createSearchUrl queryTerms, searchUrl +        title: query +        relevancy: 1 +        insertText: if useExclusiveVomnibar then query else null +        # We suppress the leading keyword, for example "w something" becomes "something" in the vomnibar. +        suppressLeadingKeyword: true +        selectCommonMatches: false +        customSearchEnginePrimarySuggestion: true +        # Toggles for the legacy behaviour. +        autoSelect: not useExclusiveVomnibar +        forceAutoSelect: not useExclusiveVomnibar +        highlightTerms: not useExclusiveVomnibar + +    mkSuggestion = do -> +      (suggestion) -> +        new Suggestion +          queryTerms: queryTerms +          type: description +          url: Utils.createSearchUrl suggestion, searchUrl +          title: suggestion +          relevancy: relavancy *= 0.9 +          insertText: suggestion +          highlightTerms: false +          selectCommonMatches: true +          customSearchEngineCompletionSuggestion: true + +    # If we have cached suggestions, then we can bundle them immediately (otherwise we'll have to fetch them +    # asynchronously). +    cachedSuggestions = CompletionSearch.complete searchUrl, queryTerms + +    # Post suggestions and bail if we already have all of the suggestions, or if there is no prospect of +    # adding further suggestions. +    if queryTerms.length == 0 or cachedSuggestions? or not haveCompletionEngine +      if cachedSuggestions? and 0 < factor +        console.log "cached suggestions:", cachedSuggestions.length, query if SearchEngineCompleter.debug +        suggestions.push cachedSuggestions.map(mkSuggestion)... +      return onComplete suggestions, { filter, continuation: null } + +    # Post any initial suggestion, and then deliver the rest of the suggestions as a continuation (so, +    # asynchronously). +    onComplete suggestions, +      filter: filter +      continuation: (onComplete) => +        CompletionSearch.complete searchUrl, queryTerms, (suggestions = []) => +          console.log "fetched suggestions:", suggestions.length, query if SearchEngineCompleter.debug +          onComplete suggestions.map mkSuggestion  # A completer which calls filter() on many completers, aggregates the results, ranks them, and returns the top  # 10. Queries from the vomnibar frontend script come through a multi completer.  class MultiCompleter -  constructor: (@completers) -> @maxResults = 10 +  maxResults: 10 -  refresh: -> completer.refresh() for completer in @completers when completer.refresh +  constructor: (@completers) -> +  refresh: (port) -> completer.refresh? port for completer in @completers +  cancel: (port) -> completer.cancel? port for completer in @completers -  filter: (queryTerms, onComplete) -> +  filter: (request, onComplete) ->      # Allow only one query to run at a time. -    if @filterInProgress -      @mostRecentQuery = { queryTerms: queryTerms, onComplete: onComplete } -      return +    return @mostRecentQuery = arguments if @filterInProgress + +    # Provide each completer with an opportunity to see (and possibly alter) the request before it is +    # launched.  Each completer is also provided with a list of all of the completers we're using +    # (request.completers), and may change that list to override the default (for example, the +    # search-engine completer does this if it wants to be the *only* completer). +    request.completers = @completers +    completer.triageRequest? request for completer in @completers +    completers = request.completers +    delete request.completers +      RegexpCache.clear() -    @mostRecentQuery = null -    @filterInProgress = true -    suggestions = [] -    completersFinished = 0 -    for completer in @completers -      # Call filter() on every source completer and wait for them all to finish before returning results. -      completer.filter queryTerms, (newSuggestions) => -        suggestions = suggestions.concat(newSuggestions) -        completersFinished += 1 -        if completersFinished >= @completers.length -          results = @sortSuggestions(suggestions)[0...@maxResults] -          result.generateHtml() for result in results -          onComplete(results) -          @filterInProgress = false -          @filter(@mostRecentQuery.queryTerms, @mostRecentQuery.onComplete) if @mostRecentQuery - -  sortSuggestions: (suggestions) -> -    suggestion.computeRelevancy(@queryTerms) for suggestion in suggestions +    { queryTerms } = request + +    [ @mostRecentQuery, @filterInProgress ] = [ null, true ] +    [ suggestions, continuations, filters ] = [ [], [], [] ] + +    # Run each of the completers (asynchronously). +    jobs = new JobRunner completers.map (completer) -> +      (callback) -> +        completer.filter request, (newSuggestions = [], { continuation, filter } = {}) -> +          suggestions.push newSuggestions... +          continuations.push continuation if continuation? +          filters.push filter if filter? +          callback() + +    # Once all completers have finished, process the results and post them, and run any continuations or +    # pending queries. +    jobs.onReady => +      suggestions = suggestions.filter filter for filter in filters +      shouldRunContinuations = 0 < continuations.length and not @mostRecentQuery? + +      # 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 +        onComplete +          results: suggestions +          mayCacheResults: continuations.length == 0 + +      # Run any continuations (asynchronously); for example, the search-engine completer +      # (SearchEngineCompleter) uses a continuation to fetch suggestions from completion engines +      # asynchronously. +      if shouldRunContinuations +        jobs = new JobRunner continuations.map (continuation) -> +          (callback) -> +            continuation (newSuggestions) -> +              suggestions.push newSuggestions... +              callback() + +        jobs.onReady => +          suggestions = @prepareSuggestions 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 +            results: suggestions +            mayCacheResults: true + +      # Admit subsequent queries, and launch any pending query. +      @filterInProgress = false +      if @mostRecentQuery +        @filter @mostRecentQuery... + +  prepareSuggestions: (queryTerms, suggestions) -> +    suggestion.computeRelevancy queryTerms for suggestion in suggestions      suggestions.sort (a, b) -> b.relevancy - a.relevancy -    suggestions +    for suggestion in suggestions[0...@maxResults] +      suggestion.generateHtml() +      suggestion  # Utilities which help us compute a relevancy score for a given item.  RankingUtils = @@ -551,8 +721,7 @@ HistoryCache =      @callbacks = null    use: (callback) -> -    return @fetchHistory(callback) unless @history? -    callback(@history) +    if @history? then callback @history else @fetchHistory callback    fetchHistory: (callback) ->      return @callbacks.push(callback) if @callbacks diff --git a/background_scripts/completion_engines.coffee b/background_scripts/completion_engines.coffee new file mode 100644 index 00000000..14e65692 --- /dev/null +++ b/background_scripts/completion_engines.coffee @@ -0,0 +1,120 @@ + +# A completion engine provides search suggestions for a search engine.  A search engine is identified by a +# "searchUrl", e.g. Settings.get("searchUrl"), or a custom search engine. +# +# Each completion engine defines three functions: +# +#   1. "match" - This takes a searchUrl and returns a boolean indicating whether this completion engine can +#      perform completion for the given search engine. +# +#   2. "getUrl" - This takes a list of query terms (queryTerms) and generates a completion URL, that is, a URL +#      which will provide completions for this completion engine. +# +#   3. "parse" - This takes a successful XMLHttpRequest object (the request has completed successfully), and +#      returns a list of suggestions (a list of strings).  This method is always executed within the context +#      of a try/catch block, so errors do not propagate. +# +# Each new completion engine must be add to the list "CompletionEngines" at the bottom of this file. +# +# The lookup logic which uses these completion engines is in "./completion_search.coffee". +# + +# A base class for common regexp-based matching engines. +class RegexpEngine +  constructor: (@regexps) -> +  match: (searchUrl) -> Utils.matchesAnyRegexp @regexps, searchUrl + +# Several Google completion engines package XML responses in this way. +class GoogleXMLRegexpEngine extends RegexpEngine +  doNotCache: false # true (disbaled, experimental) +  parse: (xhr) -> +    for suggestion in xhr.responseXML.getElementsByTagName "suggestion" +      continue unless suggestion = suggestion.getAttribute "data" +      suggestion + +class Google extends GoogleXMLRegexpEngine +  # Example search URL: http://www.google.com/search?q=%s +  constructor: -> +    super [ +      # We match the major English-speaking TLDs. +      new RegExp "^https?://[a-z]+\.google\.(com|ie|co\.uk|ca|com\.au)/" +      new RegExp "localhost/cgi-bin/booky" # Only for smblott. +      ] + +  getUrl: (queryTerms) -> +    "http://suggestqueries.google.com/complete/search?ss_protocol=legace&client=toolbar&q=#{Utils.createSearchQuery queryTerms}" + +class Youtube extends GoogleXMLRegexpEngine +  # Example search URL: http://www.youtube.com/results?search_query=%s +  constructor: -> +    super [ new RegExp "^https?://[a-z]+\.youtube\.com/results" ] + +  getUrl: (queryTerms) -> +    "http://suggestqueries.google.com/complete/search?client=youtube&ds=yt&xml=t&q=#{Utils.createSearchQuery queryTerms}" + +class Wikipedia extends RegexpEngine +  doNotCache: false # true (disbaled, experimental) +  # Example search URL: http://www.wikipedia.org/w/index.php?title=Special:Search&search=%s +  constructor: -> +    super [ new RegExp "^https?://[a-z]+\.wikipedia\.org/" ] + +  getUrl: (queryTerms) -> +    "https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search=#{Utils.createSearchQuery queryTerms}" + +  parse: (xhr) -> +    JSON.parse(xhr.responseText)[1] + +## Does not work... +## class GoogleMaps extends RegexpEngine +##   # Example search URL: https://www.google.com/maps/search/%s +##   constructor: -> +##     super [ new RegExp "^https?://www\.google\.com/maps/search/" ] +## +##   getUrl: (queryTerms) -> +##     "https://www.google.com/s?tbm=map&fp=1&gs_ri=maps&source=hp&suggest=p&authuser=0&hl=en&pf=p&tch=1&ech=2&q=#{Utils.createSearchQuery queryTerms}" +## +##   parse: (xhr) -> +##     data = JSON.parse xhr.responseText +##     [] + +class Bing extends RegexpEngine +  # Example search URL: https://www.bing.com/search?q=%s +  constructor: -> super [ new RegExp "^https?://www\.bing\.com/search" ] +  getUrl: (queryTerms) -> "http://api.bing.com/osjson.aspx?query=#{Utils.createSearchQuery queryTerms}" +  parse: (xhr) -> JSON.parse(xhr.responseText)[1] + +class Amazon extends RegexpEngine +  # Example search URL: http://www.amazon.com/s/?field-keywords=%s +  constructor: -> super [ new RegExp "^https?://www\.amazon\.(com|co.uk|ca|com.au)/s/" ] +  getUrl: (queryTerms) -> "https://completion.amazon.com/search/complete?method=completion&search-alias=aps&client=amazon-search-ui&mkt=1&q=#{Utils.createSearchQuery queryTerms}" +  parse: (xhr) -> JSON.parse(xhr.responseText)[1] + +class DuckDuckGo extends RegexpEngine +  # Example search URL: https://duckduckgo.com/?q=%s +  constructor: -> super [ new RegExp "^https?://([a-z]+\.)?duckduckgo\.com/" ] +  getUrl: (queryTerms) -> "https://duckduckgo.com/ac/?q=#{Utils.createSearchQuery queryTerms}" +  parse: (xhr) -> +    suggestion.phrase for suggestion in JSON.parse xhr.responseText + +# A dummy search engine which is guaranteed to match any search URL, but never produces completions.  This +# allows the rest of the logic to be written knowing that there will always be a completion engine match. +class DummyCompletionEngine +  dummy: true +  match: -> true +  # We return a useless URL which we know will succeed, but which won't generate any network traffic. +  getUrl: -> chrome.runtime.getURL "content_scripts/vimium.css" +  parse: -> [] + +# Note: Order matters here. +CompletionEngines = [ +  Youtube +  Google +  DuckDuckGo +  Wikipedia +  Bing +  Amazon +  DummyCompletionEngine +] + +root = exports ? window +root.CompletionEngines = CompletionEngines diff --git a/background_scripts/completion_search.coffee b/background_scripts/completion_search.coffee new file mode 100644 index 00000000..2d2ee439 --- /dev/null +++ b/background_scripts/completion_search.coffee @@ -0,0 +1,133 @@ + +CompletionSearch = +  debug: false +  inTransit: {} +  completionCache: new SimpleCache 2 * 60 * 60 * 1000, 5000 # Two hour, 5000 entries. +  engineCache:new SimpleCache 1000 * 60 * 60 * 1000 # 1000 hours. + +  # The amount of time to wait for new requests before launching the current request (for example, if the user +  # is still typing). +  delay: 100 + +  get: (searchUrl, url, callback) -> +    xhr = new XMLHttpRequest() +    xhr.open "GET", url, true +    xhr.timeout = 1000 +    xhr.ontimeout = xhr.onerror = -> callback null +    xhr.send() + +    xhr.onreadystatechange = -> +      if xhr.readyState == 4 +        callback if xhr.status == 200 then xhr else null + +  # Look up the completion engine for this searchUrl.  Because of DummyCompletionEngine, we know there will +  # always be a match. +  lookupEngine: (searchUrl) -> +    if @engineCache.has searchUrl +      @engineCache.get searchUrl +    else +      for engine in CompletionEngines +        engine = new engine() +        return @engineCache.set searchUrl, engine if engine.match searchUrl + +  # True if we have a completion engine for this search URL, false otherwise. +  haveCompletionEngine: (searchUrl) -> +    not @lookupEngine(searchUrl).dummy + +  # This is the main entry point. +  #  - searchUrl is the search engine's URL, e.g. Settings.get("searchUrl"), or a custome search engine's URL. +  #    This is only used as a key for determining the relevant completion engine. +  #  - queryTerms are the query terms. +  #  - callback will be applied to a list of suggestion strings (which may be an empty list, if anything goes +  #    wrong). +  # +  # If no callback is provided, then we're to provide suggestions only if we can do so synchronously (ie. +  # from a cache).  In this case we just return the results.  Returns null if we cannot service the request +  # synchronously. +  # +  complete: (searchUrl, queryTerms, callback = null) -> +    query = queryTerms.join(" ").toLowerCase() + +    returnResultsOnlyFromCache = not callback? +    callback ?= (suggestions) -> suggestions + +    # We don't complete queries which are too short: the results are usually useless. +    return callback [] unless 3 < query.length + +    # We don't complete regular URLs or Javascript URLs. +    return callback [] if 1 == queryTerms.length and Utils.isUrl query +    return callback [] if Utils.hasJavascriptPrefix query + +    # Cache completions.  However, completions depend upon both the searchUrl and the query terms.  So we need +    # to generate a key.  We mix in some junk generated by pwgen. A key clash might be possible, but +    # is vanishingly unlikely. +    junk = "//Zi?ei5;o//" +    completionCacheKey = searchUrl + junk + queryTerms.map((s) -> s.toLowerCase()).join junk + +    if @completionCache.has completionCacheKey +      console.log "hit", completionCacheKey if @debug +      return callback @completionCache.get completionCacheKey + +    # If the user appears to be typing a continuation of the characters of the most recent query, then we can +    # re-use the previous suggestions. +    if @mostRecentQuery? and @mostRecentSuggestions? +      reusePreviousSuggestions = do => +        # Verify that the previous query is a prefix of the current query. +        return false unless 0 == query.indexOf @mostRecentQuery.toLowerCase() +        # Verify that every previous suggestion contains the text of the new query. +        for suggestion in (@mostRecentSuggestions.map (s) -> s.toLowerCase()) +          return false unless 0 <= suggestion.indexOf query +        # Ok. Re-use the suggestion. +        true + +      if reusePreviousSuggestions +        console.log "reuse previous query:", @mostRecentQuery if @debug +        @mostRecentQuery = queryTerms.join " " +        return callback @completionCache.set completionCacheKey, @mostRecentSuggestions + +    # That's all of the caches we can try.  Bail if the caller is looking for synchronous results. +    return callback null if returnResultsOnlyFromCache + +    # We pause in case the user is still typing. +    Utils.setTimeout @delay, handler = @mostRecentHandler = => +      if handler == @mostRecentHandler +        @mostRecentHandler = null + +        # Elide duplicate requests. First fetch the suggestions... +        @inTransit[completionCacheKey] ?= new AsyncDataFetcher (callback) => +          engine = @lookupEngine searchUrl +          url = engine.getUrl queryTerms + +          @get searchUrl, url, (xhr = null) => +            # Parsing the response may fail if we receive an unexpected or an unexpectedly-formatted response. +            # In all cases, we fall back to the catch clause, below.  Therefore, we "fail safe" in the case of +            # incorrect or out-of-date completion engines. +            try +              suggestions = engine.parse xhr +              # Filter out the query itself. It's not adding anything. +              suggestions = (suggestion for suggestion in suggestions when suggestion.toLowerCase() != query) +              console.log "GET", url if @debug +            catch +              suggestions = [] +              # We allow failures to be cached too, but remove them after just thirty minutes. +              Utils.setTimeout 30 * 60 * 1000, => @completionCache.set completionCacheKey, null +              console.log "fail", url if @debug + +            callback suggestions + +        # ... then use the suggestions. +        @inTransit[completionCacheKey].use (suggestions) => +          @mostRecentQuery = query +          @mostRecentSuggestions = suggestions +          callback @completionCache.set completionCacheKey, suggestions +          delete @inTransit[completionCacheKey] + +  # Cancel any pending (ie. blocked on @delay) queries.  Does not cancel in-flight queries.  This is called +  # whenever the user is typing. +  cancel: -> +    if @mostRecentHandler? +      @mostRecentHandler = null +      console.log "cancel (user is typing)" if @debug + +root = exports ? window +root.CompletionSearch = CompletionSearch diff --git a/background_scripts/main.coffee b/background_scripts/main.coffee index dc87ac54..913f1de5 100644 --- a/background_scripts/main.coffee +++ b/background_scripts/main.coffee @@ -43,20 +43,36 @@ chrome.storage.local.set    vimiumSecret: Math.floor Math.random() * 2000000000  completionSources = -  bookmarks: new BookmarkCompleter() -  history: new HistoryCompleter() -  domains: new DomainCompleter() -  tabs: new TabCompleter() -  seachEngines: new SearchEngineCompleter() +  bookmarks: new BookmarkCompleter +  history: new HistoryCompleter +  domains: new DomainCompleter +  tabs: new TabCompleter +  searchEngines: new SearchEngineCompleter  completers = -  omni: new MultiCompleter([ -    completionSources.seachEngines, -    completionSources.bookmarks, -    completionSources.history, -    completionSources.domains]) -  bookmarks: new MultiCompleter([completionSources.bookmarks]) -  tabs: new MultiCompleter([completionSources.tabs]) +  omni: new MultiCompleter [ +    completionSources.bookmarks +    completionSources.history +    completionSources.domains +    completionSources.searchEngines +    ] +  bookmarks: new MultiCompleter [completionSources.bookmarks] +  tabs: new MultiCompleter [completionSources.tabs] + +completionHandlers = +  filter: (completer, request, port) -> +    completer.filter request, (response) -> +      # We use try here because this may fail if the sender has already navigated away from the original page. +      # This can happen, for example, when posting completion suggestions from the SearchEngineCompleter +      # (which can be slow). +      try +        port.postMessage extend request, extend response, handler: "completions" + +  refresh: (completer, _, port) -> completer.refresh port +  cancel: (completer, _, port) -> completer.cancel port + +handleCompletions = (request, port) -> +  completionHandlers[request.handler] completers[request.name], request, port  chrome.runtime.onConnect.addListener (port, name) ->    senderTabId = if port.sender.tab then port.sender.tab.id else null @@ -215,13 +231,6 @@ handleSettings = (request, port) ->        values[key] = Settings.get key for own key of values        port.postMessage { values } -refreshCompleter = (request) -> completers[request.name].refresh() - -whitespaceRegexp = /\s+/ -filterCompleter = (args, port) -> -  queryTerms = if (args.query == "") then [] else args.query.split(whitespaceRegexp) -  completers[args.name].filter(queryTerms, (results) -> port.postMessage({ id: args.id, results: results })) -  chrome.tabs.onSelectionChanged.addListener (tabId, selectionInfo) ->    if (selectionChangedHandlers.length > 0)      selectionChangedHandlers.pop().call() @@ -640,7 +649,7 @@ bgLog = (request, sender) ->  portHandlers =    keyDown: handleKeyDown,    settings: handleSettings, -  filterCompleter: filterCompleter +  completions: handleCompletions  sendRequestHandlers =    getCompletionKeys: getCompletionKeysRequest @@ -658,7 +667,6 @@ sendRequestHandlers =    pasteFromClipboard: pasteFromClipboard    isEnabledForUrl: isEnabledForUrl    selectSpecificTab: selectSpecificTab -  refreshCompleter: refreshCompleter    createMark: Marks.create.bind(Marks)    gotoMark: Marks.goto.bind(Marks)    setIcon: setIcon diff --git a/background_scripts/settings.coffee b/background_scripts/settings.coffee index 1077c84c..afc270fd 100644 --- a/background_scripts/settings.coffee +++ b/background_scripts/settings.coffee @@ -32,9 +32,6 @@ root.Settings = Settings =        root.Commands.parseCustomKeyMappings value        root.refreshCompletionKeysAfterMappingSave() -    searchEngines: (value) -> -      root.SearchEngineCompleter.parseSearchEngines value -      exclusionRules: (value) ->        root.Exclusions.postUpdateHook value @@ -46,6 +43,7 @@ root.Settings = Settings =    # or strings    defaults:      scrollStepSize: 60 +    omniSearchWeight: 0.6      smoothScroll: true      keyMappings: "# Insert your preferred key mappings here."      linkHintCharacters: "sadfjklewcmpgh" @@ -90,7 +88,24 @@ root.Settings = Settings =      # default/fall back search engine      searchUrl: "https://www.google.com/search?q="      # put in an example search engine -    searchEngines: "w: http://www.wikipedia.org/w/index.php?title=Special:Search&search=%s wikipedia" +    searchEngines: [ +      "w: http://www.wikipedia.org/w/index.php?title=Special:Search&search=%s Wikipedia" +      "" +      "# More examples." +      "#" +      "# (Vimium has built-in completion for these.)" +      "#" +      "# g: http://www.google.com/search?q=%s Google" +      "# l: http://www.google.com/search?q=%s&btnI I'm feeling lucky..." +      "# y: http://www.youtube.com/results?search_query=%s Youtube" +      "# b: https://www.bing.com/search?q=%s Bing" +      "# d: https://duckduckgo.com/?q=%s DuckDuckGo" +      "# az: http://www.amazon.com/s/?field-keywords=%s Amazon" +      "#" +      "# Another example (for Vimium does not have completion)." +      "#" +      "# m: https://www.google.com/maps/search/%s Google Maps" +      ].join "\n"      newTabUrl: "chrome://newtab"      grabBackFocus: false diff --git a/lib/utils.coffee b/lib/utils.coffee index db63d53a..a56340f5 100644 --- a/lib/utils.coffee +++ b/lib/utils.coffee @@ -107,11 +107,12 @@ Utils =      query = query.split(/\s+/) if typeof(query) == "string"      query.map(encodeURIComponent).join "+" -  # Creates a search URL from the given :query. -  createSearchUrl: (query) -> -    # It would be better to pull the default search engine from chrome itself.  However, unfortunately chrome -    # does not provide an API for doing so. -    Settings.get("searchUrl") + @createSearchQuery query +  # Create a search URL from the given :query (using either the provided search URL, or the default one). +  # It would be better to pull the default search engine from chrome itself.  However, chrome does not provide +  # an API for doing so. +  createSearchUrl: (query, searchUrl = Settings.get("searchUrl")) -> +    searchUrl += "%s" unless 0 <= searchUrl.indexOf "%s" +    searchUrl.replace /%s/g, @createSearchQuery query    # 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. @@ -185,6 +186,29 @@ Utils =      delete obj[property] for property in properties      obj +  # Does string match any of these regexps? +  matchesAnyRegexp: (regexps, string) -> +    for re in regexps +      return true if re.test string +    false + +  # Calculate the length of the longest shared prefix of a list of strings. +  longestCommonPrefix: (strings) -> +    return 0 unless 0 < strings.length +    strings.sort (a,b) -> a.length - b.length +    [ shortest, strings... ] = strings +    for ch, index in shortest.split "" +      for str in strings +        return index if ch != str[index] +    return shortest.length + +  # Convenience wrapper for setTimeout (with the arguments around the other way). +  setTimeout: (ms, func) -> setTimeout func, ms + +  # Like Nodejs's nextTick. +  nextTick: (func) -> @setTimeout 0, func + +  # This creates a new function out of an existing function, where the new function takes fewer arguments. This  # allows us to pass around functions instead of functions + a partial list of arguments.  Function::curry = -> @@ -195,6 +219,8 @@ Function::curry = ->  Array.copy = (array) -> Array.prototype.slice.call(array, 0)  String::startsWith = (str) -> @indexOf(str) == 0 +String::ltrim = -> @replace /^\s+/, "" +String::rtrim = -> @replace /\s+$/, ""  globalRoot = window ? global  globalRoot.extend = (hash1, hash2) -> @@ -202,5 +228,91 @@ globalRoot.extend = (hash1, hash2) ->      hash1[key] = hash2[key]    hash1 +# A simple cache. Entries used within two expiry periods are retained, otherwise they are discarded. +# At most 2 * @entries entries are retained. +# +# Note.  We need to be careful with @timer.  If all references to a cache are lost, then eventually its +# contents must be garbage collected, which will not happen if there are active timers. +class SimpleCache +  # expiry: expiry time in milliseconds (default, one hour) +  # entries: maximum number of entries in @cache (there may be this many entries in @previous, too) +  constructor: (@expiry = 60 * 60 * 1000, @entries = 1000) -> +    @cache = {} +    @previous = {} +    @timer = null + +  rotate: -> +    @previous = @cache +    @cache = {} +    # We reset the timer every time the cache is rotated (which could be because a previous timer expired, or +    # because the number of @entries was exceeded).  We only restart the timer if the cache is not empty. +    clearTimeout @timer if @timer? +    @timer = null +    @checkTimer() if 0 < Object.keys(@previous).length + +  checkTimer: -> +    unless @timer? +      @timer = Utils.setTimeout @expiry, => @rotate() + +  has: (key) -> +    (key of @cache) or key of @previous + +  # Set value, and return that value.  If value is null, then delete key. +  set: (key, value = null) -> +    @checkTimer() +    if value? +      @cache[key] = value +      delete @previous[key] +      @rotate() if @entries < Object.keys(@cache).length +    else +      delete @cache[key] +      delete @previous[key] +    value + +  get: (key) -> +    if key of @cache +      @cache[key] +    else if key of @previous +      @cache[key] = @previous[key] +    else +      null + +  clear: -> +    @rotate() +    @rotate() + +# This is a simple class for the common case where we want to use some data value which may be immediately +# available, or for which we may have to wait.  It implements a use-immediately-or-wait queue, and calls the +# fetch function to fetch the data asynchronously. +class AsyncDataFetcher +  constructor: (fetch) -> +    @data = null +    @queue = [] +    Utils.nextTick => +      fetch (@data) => +        callback @data for callback in @queue +        @queue = null + +  use: (callback) -> +    if @data? then callback @data else @queue.push callback + +# This takes a list of jobs (functions) and runs them, asynchronously.  Functions queued with @onReady() are +# run once all of the jobs have completed. +class JobRunner +  constructor: (@jobs) -> +    @fetcher = new AsyncDataFetcher (callback) => +      for job in @jobs +        do (job) => +          Utils.nextTick => +            job => +              @jobs = @jobs.filter (j) -> j != job +              callback true if @jobs.length == 0 + +  onReady: (callback) -> +    @fetcher.use callback +  root = exports ? window  root.Utils = Utils +root.SimpleCache = SimpleCache +root.AsyncDataFetcher = AsyncDataFetcher +root.JobRunner = JobRunner diff --git a/manifest.json b/manifest.json index a16f30fb..6445548a 100644 --- a/manifest.json +++ b/manifest.json @@ -14,6 +14,8 @@        "background_scripts/sync.js",        "background_scripts/settings.js",        "background_scripts/exclusions.js", +      "background_scripts/completion_engines.js", +      "background_scripts/completion_search.js",        "background_scripts/completion.js",        "background_scripts/marks.js",        "background_scripts/main.js" diff --git a/pages/options.coffee b/pages/options.coffee index b3ecf69a..18ff226d 100644 --- a/pages/options.coffee +++ b/pages/options.coffee @@ -261,6 +261,7 @@ initOptionsPage = ->      searchEngines: TextOption      searchUrl: NonEmptyTextOption      userDefinedLinkHintCss: TextOption +    omniSearchWeight: NumberOption    # Populate options. The constructor adds each new object to "Option.all".    for name, type of options diff --git a/pages/options.css b/pages/options.css index 5b098c8f..1a3ff757 100644 --- a/pages/options.css +++ b/pages/options.css @@ -107,9 +107,10 @@ input#linkHintNumbers {  input#linkHintCharacters {    width: 100%;  } -input#scrollStepSize { -  width: 40px; +input#scrollStepSize, input#omniSearchWeight { +  width: 50px;    margin-right: 3px; +  padding-left: 3px;  }  textarea#userDefinedLinkHintCss, textarea#keyMappings, textarea#searchEngines {    width: 100%;; diff --git a/pages/options.html b/pages/options.html index f89ddcbb..75089d75 100644 --- a/pages/options.html +++ b/pages/options.html @@ -233,6 +233,33 @@ b: http://b.com/?q=%s description                <div class="nonEmptyTextOption">              </td>            </tr> + +          <!-- Vimium Labs --> +          <tr> +            <td colspan="2"><header>Vimium Labs</header></td> +          </tr> +          <tr> +            <td class="caption"></td> +            <td> +                <div class="help"> +                  <div class="example"> +                  </div> +                </div> +                These features are experimental and may be changed or removed in future releases. +            </td> +          </tr> +          <tr> +            <td class="caption">Search weighting</td> +            <td> +                <div class="help"> +                  <div class="example"> +                    How prominent should suggestions be in the vomnibar? +                    <tt>0</tt> disables suggestions altogether. +                  </div> +                </div> +              <input id="omniSearchWeight" type="number" min="0.0" max="1.0" step="0.05" />(0 to 1) +            </td> +          </tr>          </tbody>        </table>      </div> diff --git a/pages/vomnibar.coffee b/pages/vomnibar.coffee index ce0eb61c..acf45648 100644 --- a/pages/vomnibar.coffee +++ b/pages/vomnibar.coffee @@ -9,13 +9,8 @@ Vomnibar =    completers: {}    getCompleter: (name) -> -    if (!(name of @completers)) -      @completers[name] = new BackgroundCompleter(name) -    @completers[name] +    @completers[name] ?= new BackgroundCompleter name -  # -  # Activate the Vomnibox. -  #    activate: (userOptions) ->      options =        completer: "omni" @@ -23,20 +18,17 @@ Vomnibar =        newTab: false        selectFirst: false      extend options, userOptions +    extend options, refreshInterval: if options.completer == "omni" then 150 else 0 -    options.refreshInterval = switch options.completer -      when "omni" then 100 -      else 0 - -    completer = @getCompleter(options.completer) +    completer = @getCompleter options.completer      @vomnibarUI ?= new VomnibarUI() -    completer.refresh() -    @vomnibarUI.setInitialSelectionValue(if options.selectFirst then 0 else -1) -    @vomnibarUI.setCompleter(completer) -    @vomnibarUI.setRefreshInterval(options.refreshInterval) -    @vomnibarUI.setForceNewTab(options.newTab) -    @vomnibarUI.setQuery(options.query) -    @vomnibarUI.update() +    completer.refresh @vomnibarUI +    @vomnibarUI.setInitialSelectionValue if options.selectFirst then 0 else -1 +    @vomnibarUI.setCompleter completer +    @vomnibarUI.setRefreshInterval options.refreshInterval +    @vomnibarUI.setForceNewTab options.newTab +    @vomnibarUI.setQuery options.query +    @vomnibarUI.update true    hide: -> @vomnibarUI?.hide()    onHidden: -> @vomnibarUI?.onHidden() @@ -48,18 +40,11 @@ class VomnibarUI      @initDom()    setQuery: (query) -> @input.value = query - -  setInitialSelectionValue: (initialSelectionValue) -> -    @initialSelectionValue = initialSelectionValue - -  setCompleter: (completer) -> -    @completer = completer -    @reset() -    @update(true) - -  setRefreshInterval: (refreshInterval) -> @refreshInterval = refreshInterval - -  setForceNewTab: (forceNewTab) -> @forceNewTab = forceNewTab +  setInitialSelectionValue: (@initialSelectionValue) -> +  setRefreshInterval: (@refreshInterval) -> +  setForceNewTab: (@forceNewTab) -> +  setCompleter: (@completer) -> @reset() +  setKeywords: (@keywords) ->    # The sequence of events when the vomnibar is hidden is as follows:    # 1. Post a "hide" message to the host page. @@ -71,32 +56,101 @@ class VomnibarUI    hide: (@postHideCallback = null) ->      UIComponentServer.postMessage "hide"      @reset() +    @completer?.reset()    onHidden: ->      @postHideCallback?()      @postHideCallback = null    reset: -> +    @clearUpdateTimer()      @completionList.style.display = ""      @input.value = "" -    @updateTimer = null      @completions = [] +    @previousAutoSelect = null +    @previousInputValue = null +    @suppressedLeadingKeyword = null +    @previousLength = 0      @selection = @initialSelectionValue +    @keywords = []    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 @completions[0] +    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 @completions[0]?.suppressLeadingKeyword and not @suppressedLeadingKeyword? +      queryTerms = @input.value.trim().split /\s+/ +      @suppressedLeadingKeyword = queryTerms[0] +      @input.value = queryTerms[1..].join " " + +    # For suggestions from search-engine completion, we copy the suggested text into the input when the item +    # is selected, and revert when it is not.  This allows the user to select a suggestion and then continue +    # typing. +    if 0 <= @selection and @completions[@selection].insertText? +      @previousInputValue ?= +        value: @input.value +        selectionStart: @input.selectionStart +        selectionEnd: @input.selectionEnd +      @input.value = @completions[@selection].insertText + (if @selection == 0 then "" else " ") +    else if @previousInputValue? +      # Restore the text. +      @input.value = @previousInputValue.value +      # Restore the selection. +      if @previousInputValue.selectionStart? and @previousInputValue.selectionEnd? and +        @previousInputValue.selectionStart != @previousInputValue.selectionEnd +          @input.setSelectionRange @previousInputValue.selectionStart, @previousInputValue.selectionEnd +      @previousInputValue = null + +    # Highlight the selected entry, and only the selected entry.      for i in [0...@completionList.children.length]        @completionList.children[i].className = (if i == @selection then "vomnibarSelected" else "") -  # -  # Returns the user's action ("up", "down", "enter", "dismiss" or null) based on their keypress. -  # We support the arrow keys and other shortcuts for moving, so this method hides that complexity. -  # +  # This adds prompted text to the vomnibar input.  The propted text is a continuation of the text the user +  # has typed already, taken from one of the search suggestions.  It is highlight (using the selection) and +  # will be included with the query should the user type <Enter>. +  addPromptedText: (response) -> +    # Bail if we don't yet have the background completer's final word on the current query. +    return unless response.mayCacheResults + +    # Bail if there's an update pending (because then @input and the completion state are out of sync). +    return if @updateTimer? + +    value = @getInputWithoutPromptedText() +    @previousLength ?= value.length +    previousLength = @previousLength +    currentLength = value.length +    @previousLength = currentLength + +    return unless previousLength < currentLength +    return if /^\s/.test(value) or /\s\s/.test value + +    completions = @completions.filter (completion) -> completion.customSearchEngineCompletionSuggestion +    return unless 0 < completions.length + +    query = value.ltrim().split(/\s+/).join(" ").toLowerCase() +    suggestion = completions[0].title + +    index = suggestion.toLowerCase().indexOf query +    return unless 0 <= index and index + query.length < suggestion.length + +    # If the typed text is all lower case, then make the prompted text lower case too. +    suggestion = suggestion[index..] +    suggestion = suggestion.toLowerCase() unless /[A-Z]/.test @getInputWithoutPromptedText() + +    suggestion = suggestion[query.length..] +    @input.value = query + suggestion +    @input.setSelectionRange query.length, query.length + suggestion.length + +  # Returns the user's action ("up", "down", "tab", etc, or null) based on their keypress.  We support the +  # arrow keys and various other shortcuts, and this function hides the event-decoding complexity.    actionFromKeyEvent: (event) ->      key = KeyboardUtils.getKeyChar(event)      if (KeyboardUtils.isEscape(event)) @@ -105,12 +159,20 @@ class VomnibarUI          (event.shiftKey && event.keyCode == keyCodes.tab) ||          (event.ctrlKey && (key == "k" || key == "p")))        return "up" +    else if (event.keyCode == keyCodes.tab && !event.shiftKey) +      return "tab"      else if (key == "down" || -        (event.keyCode == keyCodes.tab && !event.shiftKey) ||          (event.ctrlKey && (key == "j" || key == "n")))        return "down"      else if (event.keyCode == keyCodes.enter)        return "enter" +    else if event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey +      return "delete" +    else if key in [ "left", "right" ] and event.ctrlKey and +        not (event.altKey or event.metaKey or event.shiftKey) +      return "control-#{key}" + +    null    onKeydown: (event) =>      action = @actionFromKeyEvent(event) @@ -120,69 +182,147 @@ class VomnibarUI        (event.shiftKey || event.ctrlKey || KeyboardUtils.isPrimaryModifierKey(event))      if (action == "dismiss")        @hide() +    else if action in [ "tab", "down" ] +      @selection += 1 +      @selection = @initialSelectionValue if @selection == @completions.length +      @updateSelection()      else if (action == "up")        @selection -= 1        @selection = @completions.length - 1 if @selection < @initialSelectionValue        @updateSelection() -    else if (action == "down") -      @selection += 1 -      @selection = @initialSelectionValue if @selection == @completions.length -      @updateSelection()      else if (action == "enter") -      # If they type something and hit enter without selecting a completion from our list of suggestions, -      # try to open their query as a URL directly. If it doesn't look like a URL, we will search using -      # google. -      if (@selection == -1) +      if @selection == -1 +        # The user has not selected a suggestion.          query = @input.value.trim()          # <Enter> on an empty vomnibar is a no-op.          return unless 0 < query.length -        @hide -> -          chrome.runtime.sendMessage -            handler: if openInNewTab then "openUrlInNewTab" else "openUrlInCurrentTab" -            url: query +        if @suppressedLeadingKeyword? +          # This is a custom search engine completion.  Because of the way we add prompted text to the input +          # (addPromptedText), the text in the input might not correspond to any of the completions.  So we +          # fire off the query to the background page and use the completion at the top of the list (which +          # will be the right one). +          window.getSelection()?.collapseToEnd() if @inputContainsASelectionRange() +          @update true, => +            if @completions[0] +              completion = @completions[0] +              @hide -> completion.performAction openInNewTab +        else +          # If the user types something and hits enter without selecting a completion from the list, then try +          # to open their query as a URL directly. If it doesn't look like a URL, then use the default search +          # engine. +          @hide -> +            chrome.runtime.sendMessage +              handler: if openInNewTab then "openUrlInNewTab" else "openUrlInCurrentTab" +              url: query +      else +        completion = @completions[@selection] +        @hide -> completion.performAction openInNewTab +    else if action == "delete" +      if @suppressedLeadingKeyword? and @input.value.length == 0 +        # Normally, with custom search engines, the keyword (e,g, the "w" of "w query terms") suppressed.  If +        # the input is empty, then show the keyword again. +        @input.value = @suppressedLeadingKeyword +        @suppressedLeadingKeyword = null +        @updateCompletions()        else -        @update true, => -          # Shift+Enter will open the result in a new tab instead of the current tab. -          completion = @completions[@selection] -          @hide -> completion.performAction openInNewTab +        # Don't suppress the Delete.  We want it to happen. +        return true +    else if action == "control-right" +      [ start, end ] = [ @input.selectionStart, @input.selectionEnd ] +      return true unless @inputContainsASelectionRange() and end == @input.value.length +      # "Control-Right" advances the start of the selection by a word. +      text = @input.value[start...end] +      newText = text.replace /^\s*\S+\s*/, "" +      @input.setSelectionRange start + (text.length - newText.length), end + +    else if action == "control-left" +      [ start, end ] = [ @input.selectionStart, @input.selectionEnd ] +      return true unless @inputContainsASelectionRange() and end == @input.value.length +      # "Control-Left" extends the start of the selection to the start of the current word. +      text = @input.value[0...start] +      newText = text.replace /\S+\s*$/, "" +      @input.setSelectionRange start + (newText.length - text.length), end      # It seems like we have to manually suppress the event here and still return true.      event.stopImmediatePropagation()      event.preventDefault()      true -  updateCompletions: (callback) -> -    query = @input.value.trim() - -    @completer.filter query, (completions) => -      @completions = completions -      @populateUiWithCompletions(completions) -      callback() if callback - -  populateUiWithCompletions: (completions) -> -    # update completion list with the new data -    @completionList.innerHTML = completions.map((completion) -> "<li>#{completion.html}</li>").join("") -    @completionList.style.display = if completions.length > 0 then "block" else "" -    @selection = Math.min(Math.max(@initialSelectionValue, @selection), @completions.length - 1) -    @updateSelection() - -  update: (updateSynchronously, callback) => -    if (updateSynchronously) -      # cancel scheduled update -      if (@updateTimer != null) -        window.clearTimeout(@updateTimer) -        @updateTimer = null -      @updateCompletions(callback) -    else if (@updateTimer != null) -      # an update is already scheduled, don't do anything -      return +  onKeypress: (event) => +    # Handle typing with prompted text. +    unless event.altKey or event.ctrlKey or event.metaKey +      if @inputContainsASelectionRange() +        # As the user types characters which the match prompted text, we suppress the keyboard event and +        # simulate it by advancing the start of the selection (but only if the typed character matches).  This +        # avoids flicker (if we were to allow the event through) as the selection is first collapsed then +        # restored. +        if @input.value[@input.selectionStart][0].toLowerCase() == (String.fromCharCode event.charCode).toLowerCase() +          @input.setSelectionRange @input.selectionStart + 1, @input.selectionEnd +          @updateOnInput() +          event.stopImmediatePropagation() +          event.preventDefault() +    true + +  # Test whether the input contains prompted text. +  inputContainsASelectionRange: -> +    @input.selectionStart? and @input.selectionEnd? and @input.selectionStart != @input.selectionEnd + +  # Return the text of the input, with any selected text removed. +  getInputWithoutPromptedText: -> +    if @inputContainsASelectionRange() +      @input.value[0...@input.selectionStart] + @input.value[@input.selectionEnd..]      else -      # always update asynchronously for better user experience and to take some load off the CPU -      # (not every keystroke will cause a dedicated update) -      @updateTimer = setTimeout(=> -        @updateCompletions(callback) +      @input.value + +  # Return the background-page query corresponding to the current input state.  In other words, reinstate any +  # search engine keyword which is currently being suppressed, and strip any propted text. +  getInputValueAsQuery: -> +    (if @suppressedLeadingKeyword? then @suppressedLeadingKeyword + " " else "") + @getInputWithoutPromptedText() + +  updateCompletions: (callback = null) -> +    @completer.filter @getInputValueAsQuery(), (response) => +      { results, mayCacheResults } = response +      @completions = results +      # 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() +      @addPromptedText response +      callback?() + +  updateOnInput: => +    @completer.cancel() +    # If the user types, then don't reset any previous text, and restart auto select. +    if @previousInputValue? +      @previousInputValue = null +      @previousAutoSelect = null +      @selection = -1 +    @update false + +  clearUpdateTimer: -> +    if @updateTimer? +      window.clearTimeout @updateTimer +      @updateTimer = null + +  isCustomSearch: -> +    queryTerms = @input.value.ltrim().split /\s+/ +    1 < queryTerms.length and queryTerms[0] in @keywords + +  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 @suppressedLeadingKeyword? +    if updateSynchronously +      @clearUpdateTimer() +      @updateCompletions callback +    else if not @updateTimer? +      # Update asynchronously for a better user experience, and to take some load off the CPU (not every +      # keystroke will cause a dedicated update). +      @updateTimer = Utils.setTimeout @refreshInterval, =>          @updateTimer = null -      @refreshInterval) +        @updateCompletions callback      @input.focus() @@ -190,8 +330,9 @@ class VomnibarUI      @box = document.getElementById("vomnibar")      @input = @box.querySelector("input") -    @input.addEventListener "input", @update +    @input.addEventListener "input", @updateOnInput      @input.addEventListener "keydown", @onKeydown +    @input.addEventListener "keypress", @onKeypress      @completionList = @box.querySelector("ul")      @completionList.style.display = "" @@ -204,54 +345,86 @@ class VomnibarUI      document.body.addEventListener "click", => @hide()  # -# Sends filter and refresh requests to a Vomnibox completer on the background page. +# Sends requests to a Vomnibox completer on the background page.  #  class BackgroundCompleter -  # We increment this counter on each message sent, and ignore responses which arrive too late. -  @messageId: 0 +  debug: false -  # - name: The background page completer that you want to interface with. Either "omni", "tabs", or -  # "bookmarks". */ +  # The "name" is the background-page completer to connect to: "omni", "tabs", or "bookmarks".    constructor: (@name) -> -    @filterPort = chrome.runtime.connect name: "filterCompleter" - -  refresh: -> -    chrome.runtime.sendMessage handler: "refreshCompleter", name: @name +    @port = chrome.runtime.connect name: "completions" +    @messageId = null +    @reset() -  filter: (query, callback) -> -    id = BackgroundCompleter.messageId += 1 -    @filterPort.onMessage.addListener handler = (msg) => -      if msg.id == id -        @filterPort.onMessage.removeListener handler -        if id == BackgroundCompleter.messageId +    @port.onMessage.addListener (msg) => +      switch msg.handler +        when "keywords" +          @keywords = msg.keywords +          @lastUI.setKeywords @keywords +        when "completions"            # The result objects coming from the background page will be of the form: -          #   { html: "", type: "", url: "" } -          # type will be one of [tab, bookmark, history, domain]. -          results = msg.results.map (result) -> -            functionToCall = if (result.type == "tab") -              BackgroundCompleter.completionActions.switchToTab.curry(result.tabId) -            else -              BackgroundCompleter.completionActions.navigateToUrl.curry(result.url) -            result.performAction = functionToCall -            result -          callback(results) - -    @filterPort.postMessage id: id, name: @name, query: query - -extend BackgroundCompleter, -  # -  # These are the actions we can perform when the user selects a result in the Vomnibox. -  # +          #   { html: "", type: "", url: "", ... } +          # Type will be one of [tab, bookmark, history, domain, search], or a custom search engine description. +          for result in msg.results +            extend result, +              performAction: +                if result.type == "tab" +                  @completionActions.switchToTab result.tabId +                else +                  @completionActions.navigateToUrl result.url + +          # Cache the results, but only if we have been told it's ok to do so (it could be that more results +          # will be posted shortly).  We cache the results even if they arrive late. +          if msg.mayCacheResults +            console.log "cache set:", "-#{msg.cacheKey}-" if @debug +            @cache[msg.cacheKey] = msg +          else +            console.log "not setting cache:", "-#{msg.cacheKey}-" if @debug + +          # Handle the message, but only if it hasn't arrived too late. +          @mostRecentCallback msg if msg.id == @messageId + +  filter: (query, @mostRecentCallback) -> +    cacheKey = query.ltrim().split(/\s+/).join " " + +    if cacheKey of @cache +      console.log "cache hit:", "-#{cacheKey}-" if @debug +      @mostRecentCallback @cache[cacheKey] +    else +      console.log "cache miss:", "-#{cacheKey}-" if @debug +      @port.postMessage +        handler: "filter" +        name: @name +        id: @messageId = Utils.createUniqueId() +        queryTerms: query.trim().split(/\s+/).filter (s) -> 0 < s.length +        query: query +        cacheKey: cacheKey + +  reset: -> +    [ @keywords, @cache ] = [ [], {} ] + +  refresh: (@lastUI) -> +    @reset() +    @port.postMessage name: @name, handler: "refresh" + +  cancel: -> +    # Inform the background completer that it may (should it choose to do so) abandon any pending query +    # (because the user is typing, and there will be another query along soon). +    @port.postMessage name: @name, handler: "cancel" + +  # These are the actions we can perform when the user selects a result.    completionActions: -    navigateToUrl: (url, openInNewTab) -> -      # If the URL is a bookmarklet prefixed with javascript:, we shouldn't open that in a new tab. -      openInNewTab = false if url.startsWith("javascript:") -      chrome.runtime.sendMessage( +    navigateToUrl: (url) -> (openInNewTab) -> +      # If the URL is a bookmarklet (so, prefixed with "javascript:"), then we always open it in the current +      # tab. +      openInNewTab &&= not Utils.hasJavascriptPrefix url +      chrome.runtime.sendMessage          handler: if openInNewTab then "openUrlInNewTab" else "openUrlInCurrentTab" -        url: url, -        selected: openInNewTab) +        url: url +        selected: openInNewTab -    switchToTab: (tabId) -> chrome.runtime.sendMessage({ handler: "selectSpecificTab", id: tabId }) +    switchToTab: (tabId) -> -> +      chrome.runtime.sendMessage handler: "selectSpecificTab", id: tabId  UIComponentServer.registerHandler (event) ->    switch event.data diff --git a/pages/vomnibar.css b/pages/vomnibar.css index 2042a6c4..9fdc43ba 100644 --- a/pages/vomnibar.css +++ b/pages/vomnibar.css @@ -134,3 +134,14 @@    font-weight: normal;  } +#vomnibarInput::selection { +  /* This is the light grey color of the vomnibar border. */ +  /* background-color: #F1F1F1; */ + +  /* This is the light blue color of the vomnibar selected item. */ +  /* background-color: #BBCEE9; */ + +  /* This is a considerably lighter blue than Vimium blue, which seems softer +   * on the eye for this purpose. */ +  background-color: #E6EEFB; +} diff --git a/pages/vomnibar.html b/pages/vomnibar.html index 2ca463d0..87acc081 100644 --- a/pages/vomnibar.html +++ b/pages/vomnibar.html @@ -14,7 +14,7 @@    <body>      <div id="vomnibar" class="vimiumReset">        <div class="vimiumReset vomnibarSearchArea"> -        <input type="text" class="vimiumReset"> +        <input id="vomnibarInput" type="text" class="vimiumReset">        </div>        <ul class="vimiumReset"></ul>      </div> diff --git a/tests/dom_tests/vomnibar_test.coffee b/tests/dom_tests/vomnibar_test.coffee index 0e02bb7b..e32c050d 100644 --- a/tests/dom_tests/vomnibar_test.coffee +++ b/tests/dom_tests/vomnibar_test.coffee @@ -14,7 +14,7 @@ context "Keep selection within bounds",      oldGetCompleter = vomnibarFrame.Vomnibar.getCompleter.bind vomnibarFrame.Vomnibar      stub vomnibarFrame.Vomnibar, 'getCompleter', (name) =>        completer = oldGetCompleter name -      stub completer, 'filter', (query, callback) => callback(@completions) +      stub completer, 'filter', (query, callback) => callback results: @completions        completer      # Shoulda.js doesn't support async tests, so we have to hack around. diff --git a/tests/unit_tests/completion_test.coffee b/tests/unit_tests/completion_test.coffee index 56fcc456..7711dac4 100644 --- a/tests/unit_tests/completion_test.coffee +++ b/tests/unit_tests/completion_test.coffee @@ -1,5 +1,6 @@  require "./test_helper.js"  extend(global, require "../../lib/utils.js") +extend(global, require "../../background_scripts/completion_engines.js")  extend(global, require "../../background_scripts/completion.js")  extend global, require "./test_chrome_stubs.js" @@ -235,44 +236,43 @@ context "tab completer",      assert.arrayEqual ["tab2.com"], results.map (tab) -> tab.url      assert.arrayEqual [2], results.map (tab) -> tab.tabId -context "search engines", -  setup -> -    searchEngines = "foo: bar?q=%s\n# comment\nbaz: qux?q=%s baz description" -    Settings.set 'searchEngines', searchEngines -    @completer = new SearchEngineCompleter() -    # note, I couldn't just call @completer.refresh() here as I couldn't set root.Settings without errors -    # workaround is below, would be good for someone that understands the testing system better than me to improve -    @completer.searchEngines = SearchEngineCompleter.getSearchEngines() - -  should "return search engine suggestion without description", -> -    results = filterCompleter(@completer, ["foo", "hello"]) -    assert.arrayEqual ["bar?q=hello"], results.map (result) -> result.url -    assert.arrayEqual ["foo: hello"], results.map (result) -> result.title -    assert.arrayEqual ["search"], results.map (result) -> result.type - -  should "return search engine suggestion with description", -> -    results = filterCompleter(@completer, ["baz", "hello"]) -    assert.arrayEqual ["qux?q=hello"], results.map (result) -> result.url -    assert.arrayEqual ["hello"], results.map (result) -> result.title -    assert.arrayEqual ["baz description"], results.map (result) -> result.type -  context "suggestions",    should "escape html in page titles", -> -    suggestion = new Suggestion(["queryterm"], "tab", "url", "title <span>", returns(1)) +    suggestion = new Suggestion +      queryTerms: ["queryterm"] +      type: "tab" +      url: "url" +      title: "title <span>" +      relevancyFunction: returns 1      assert.isTrue suggestion.generateHtml().indexOf("title <span>") >= 0    should "highlight query words", -> -    suggestion = new Suggestion(["ninj", "words"], "tab", "url", "ninjawords", returns(1)) +    suggestion = new Suggestion +      queryTerms: ["ninj", "words"] +      type: "tab" +      url: "url" +      title: "ninjawords" +      relevancyFunction: returns 1      expected = "<span class='vomnibarMatch'>ninj</span>a<span class='vomnibarMatch'>words</span>"      assert.isTrue suggestion.generateHtml().indexOf(expected) >= 0    should "highlight query words correctly when whey they overlap", -> -    suggestion = new Suggestion(["ninj", "jaword"], "tab", "url", "ninjawords", returns(1)) +    suggestion = new Suggestion +      queryTerms: ["ninj", "jaword"] +      type: "tab" +      url: "url" +      title: "ninjawords" +      relevancyFunction: returns 1      expected = "<span class='vomnibarMatch'>ninjaword</span>s"      assert.isTrue suggestion.generateHtml().indexOf(expected) >= 0    should "shorten urls", -> -    suggestion = new Suggestion(["queryterm"], "tab", "http://ninjawords.com", "ninjawords", returns(1)) +    suggestion = new Suggestion +      queryTerms: ["queryterm"] +      type: "tab" +      url: "http://ninjawords.com" +      title: "ninjawords" +      relevancyFunction: returns 1      assert.equal -1, suggestion.generateHtml().indexOf("http://ninjawords.com")  context "RankingUtils.wordRelevancy", @@ -465,7 +465,7 @@ context "TabRecency",  # A convenience wrapper around completer.filter() so it can be called synchronously in tests.  filterCompleter = (completer, queryTerms) ->    results = [] -  completer.filter(queryTerms, (completionResults) -> results = completionResults) +  completer.filter({ queryTerms }, (completionResults) -> results = completionResults)    results  hours = (n) -> 1000 * 60 * 60 * n diff --git a/tests/unit_tests/settings_test.coffee b/tests/unit_tests/settings_test.coffee index 346c98da..4cd20211 100644 --- a/tests/unit_tests/settings_test.coffee +++ b/tests/unit_tests/settings_test.coffee @@ -70,15 +70,5 @@ context "settings",      chrome.storage.sync.set { scrollStepSize: JSON.stringify(message) }      assert.equal message, Sync.message -  should "set search engines, retrieve them correctly and check that they have been parsed correctly", -> -    searchEngines = "foo: bar?q=%s\n# comment\nbaz: qux?q=%s baz description" -    Settings.set 'searchEngines', searchEngines -    result = SearchEngineCompleter.getSearchEngines() -    assert.equal Object.keys(result).length, 2 -    assert.equal "bar?q=%s", result["foo"].url -    assert.isFalse result["foo"].description -    assert.equal "qux?q=%s", result["baz"].url -    assert.equal "baz description", result["baz"].description -    should "sync a key which is not a known setting (without crashing)", ->      chrome.storage.sync.set { notASetting: JSON.stringify("notAUsefullValue") } | 
