aboutsummaryrefslogtreecommitdiffstats
path: root/background_scripts/completion_search.coffee
blob: eb27c076f460fd44526f66080d813cd75b56b8f7 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
CompletionSearch =
  debug: true

  # The amount of time to wait for new requests before launching the HTTP request.  The intention is to cut
  # down on the number of HTTP requests we issue.
  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, above, we know there
  # will always be a match.  Imagining that there may be many completion engines, and knowing that this is
  # called for every query, we cache the result.
  lookupEngine: (searchUrl) ->
    @engineCache ?= new SimpleCache 30 * 60 * 60 * 1000 # 30 hours (these are small, we can keep them longer).
    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, undefined 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).
  complete: (searchUrl, queryTerms, callback = null) ->
    query = queryTerms.join ""

    # 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.  Return null if we cannot service the request
    # synchronously.
    returnResultsOnlyFromCache = not callback?
    callback ?= (suggestions) -> suggestions

    # We don't complete single characters: the results are usually useless.
    return callback [] unless 1 < 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
    # vanishingly unlikely.
    junk = "//Zi?ei5;o//"
    completionCacheKey = searchUrl + junk + queryTerms.map((s) -> s.toLowerCase()).join junk
    @completionCache ?= new SimpleCache 60 * 60 * 1000, 2000 # One hour, 2000 entries.
    if @completionCache.has completionCacheKey
      if returnResultsOnlyFromCache
        return callback @completionCache.get completionCacheKey
      else
        # We add a short delay, even for a cache hit.  This avoids an ugly flicker when the additional
        # suggestions are posted.
        Utils.setTimeout 50, =>
          console.log "hit", completionCacheKey if @debug
          callback @completionCache.get completionCacheKey
        return

    # 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 (query) =>
        query = queryTerms.join(" ").toLowerCase()
        # Verify that the previous query is a prefix of the current query.
        return false unless 0 == query.indexOf @mostRecentQuery.toLowerCase()
        # Ensure 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

    fetchSuggestions = (engine, callback) =>
      url = engine.getUrl queryTerms
      query = queryTerms.join(" ").toLowerCase()
      @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 cache failures 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

    # We pause in case the user is still typing.
    Utils.setTimeout @delay, handler = @mostRecentHandler = =>
      if handler == @mostRecentHandler
        @mostRecentHandler = null

        # Share duplicate requests. First fetch the suggestions...
        @inTransit ?= {}
        @inTransit[completionCacheKey] ?= new AsyncDataFetcher (callback) =>
          fetchSuggestions @lookupEngine(searchUrl), callback

        # ... then use the suggestions.
        @inTransit[completionCacheKey].use (suggestions) =>
          @mostRecentQuery = queryTerms.join " "
          @mostRecentSuggestions = suggestions
          callback @completionCache.set completionCacheKey, suggestions
          delete @inTransit[completionCacheKey]

  # Cancel any pending (ie. blocked on @delay) queries.  Does not cancel in-flight queries.
  cancel: ->
    if @mostRecentHandler?
      @mostRecentHandler = null
      console.log "cancel (user is typing)" if @debug

root = exports ? window
root.CompletionSearch = CompletionSearch