aboutsummaryrefslogtreecommitdiffstats
path: root/background_scripts/completion_search.coffee
diff options
context:
space:
mode:
authorStephen Blott2015-05-10 15:19:25 +0100
committerStephen Blott2015-05-10 15:54:42 +0100
commit6b52c9e6397ac0a040c1bd46b7e6825a0a8415d2 (patch)
treebfd7281a66e456f50892251c59c0a6c473c4fc72 /background_scripts/completion_search.coffee
parent8493811a4279950194cc8b1f5941cf9730cda1f0 (diff)
downloadvimium-6b52c9e6397ac0a040c1bd46b7e6825a0a8415d2.tar.bz2
Search completion; move completion engines to their own file.
Diffstat (limited to 'background_scripts/completion_search.coffee')
-rw-r--r--background_scripts/completion_search.coffee140
1 files changed, 140 insertions, 0 deletions
diff --git a/background_scripts/completion_search.coffee b/background_scripts/completion_search.coffee
new file mode 100644
index 00000000..eb27c076
--- /dev/null
+++ b/background_scripts/completion_search.coffee
@@ -0,0 +1,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