aboutsummaryrefslogtreecommitdiffstats
path: root/background_scripts/completion_engines.coffee
diff options
context:
space:
mode:
Diffstat (limited to 'background_scripts/completion_engines.coffee')
-rw-r--r--background_scripts/completion_engines.coffee219
1 files changed, 219 insertions, 0 deletions
diff --git a/background_scripts/completion_engines.coffee b/background_scripts/completion_engines.coffee
new file mode 100644
index 00000000..0177806a
--- /dev/null
+++ b/background_scripts/completion_engines.coffee
@@ -0,0 +1,219 @@
+
+# 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).
+#
+# The main completion entry point is CompletionEngines.complete(). This implements all lookup and caching
+# logic. It is possible to add new completion engines without changing the CompletionEngines infrastructure
+# itself.
+
+# A base class for common regexp-based matching engines.
+class RegexpEngine
+ constructor: (@regexps) ->
+ match: (searchUrl) -> Utils.matchesAnyRegexp @regexps, searchUrl
+
+# Several Google completion engines package responses in this way.
+class GoogleXMLRegexpEngine extends RegexpEngine
+ 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
+ # 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]
+
+## 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) ->
+## console.log "xxxxxxxxxxxxxxxxxxxxx"
+## "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) ->
+## console.log "yyy", xhr.responseText
+## data = JSON.parse xhr.responseText
+## console.log "zzz"
+## console.log data
+## []
+
+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 be a search engine match.
+class DummySearchEngine
+ 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: -> []
+
+completionEngines = [
+ Youtube
+ Google
+ DuckDuckGo
+ Wikipedia
+ Bing
+ Amazon
+ DummySearchEngine
+]
+
+CompletionEngines =
+ debug: true
+
+ 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 search-completion engine for this searchUrl. Because of DummySearchEngine, 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 input event in the vomnibar, 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
+
+ # This is the main (actually, the only) 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 queryTerms.
+ # - callback will be applied to a list of suggestion strings (which may be an empty list, if anything goes
+ # wrong).
+ complete: (searchUrl, queryTerms, callback) ->
+ @mostRecentHandler = null
+
+ # We can't complete empty queries.
+ return callback [] unless 0 < queryTerms.length
+
+ if 1 == queryTerms.length
+ # We don't complete URLs.
+ return callback [] if Utils.isUrl queryTerms[0]
+ # We don't complete less then three characters: the results are usually useless. This also prevents
+ # one- and two-character custom search engine keywords from being sent to the default completer (e.g.
+ # the initial "w" before typing "w something").
+ return callback [] unless 2 < queryTerms[0].length
+
+ # We don't complete Javascript URLs.
+ return callback [] if Utils.hasJavascriptPrefix queryTerms[0]
+
+ # 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 is possible, but vanishingly
+ # unlikely.
+ junk = "//Zi?ei5;o//"
+ completionCacheKey = searchUrl + junk + queryTerms.join junk
+ @completionCache ?= new SimpleCache 60 * 60 * 1000, 2000 # One hour, 2000 entries.
+ if @completionCache.has completionCacheKey
+ console.log "hit", completionCacheKey if @debug
+ return callback @completionCache.get completionCacheKey
+
+ fetchSuggestions = (callback) =>
+ engine = @lookupEngine searchUrl
+ url = engine.getUrl queryTerms
+ console.log "get", url if @debug
+ 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.
+ try
+ suggestions = engine.parse xhr
+ # Make sure we really do have an iterable of strings.
+ suggestions = (suggestion for suggestion in suggestions when "string" == typeof suggestion)
+ # Filter out the query itself. It's not adding anything.
+ suggestions = (suggestion for suggestion in suggestions when suggestion.toLowerCase() != query)
+ catch
+ suggestions = []
+ # We cache failures, but remove them after just ten minutes. This (it is hoped) avoids repeated
+ # XMLHttpRequest failures over a short period of time.
+ removeCompletionCacheKey = => @completionCache.set completionCacheKey, null
+ setTimeout removeCompletionCacheKey, 10 * 60 * 1000 # Ten minutes.
+
+ callback suggestions
+
+ # We pause in case the user is still typing.
+ Utils.setTimeout 200, handler = @mostRecentHandler = =>
+ if handler != @mostRecentHandler # Bail if another completion has begun, or the user is typing.
+ console.log "bail", completionCacheKey if @debug
+ return callback []
+ # Don't allow duplicate identical active requests. This can happen, for example, when the user enters or
+ # removes a space, or when they enter a character and immediately delete it.
+ @inTransit ?= {}
+ unless @inTransit[completionCacheKey]?.push callback
+ queue = @inTransit[completionCacheKey] = []
+ fetchSuggestions (suggestions) =>
+ callback @completionCache.set completionCacheKey, suggestions
+ delete @inTransit[completionCacheKey]
+ console.log "callbacks", queue.length, completionCacheKey if @debug and 0 < queue.length
+ callback suggestions for callback in queue
+
+ userIsTyping: ->
+ console.log "reset (typing)" if @debug and @mostRecentHandler?
+ @mostRecentHandler = null
+
+root = exports ? window
+root.CompletionEngines = CompletionEngines