aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorStephen Blott2015-05-11 14:55:00 +0100
committerStephen Blott2015-05-11 14:55:00 +0100
commit104dc7ff2c88c7df9760c6ca35991d9c160bbb35 (patch)
treeecf9af8cfc32756308f39e12d4c10df43304e143
parent3503b5a510416e04449272a461e6765b7e7cd3f7 (diff)
parent4ba12991a277d193969e87706facdba12fdee4d0 (diff)
downloadvimium-104dc7ff2c88c7df9760c6ca35991d9c160bbb35.tar.bz2
Merge branch 'search-engine-completion-v5'
-rw-r--r--background_scripts/completion.coffee419
-rw-r--r--background_scripts/completion_engines.coffee120
-rw-r--r--background_scripts/completion_search.coffee133
-rw-r--r--background_scripts/main.coffee50
-rw-r--r--background_scripts/settings.coffee23
-rw-r--r--lib/utils.coffee122
-rw-r--r--manifest.json2
-rw-r--r--pages/options.coffee1
-rw-r--r--pages/options.css5
-rw-r--r--pages/options.html27
-rw-r--r--pages/vomnibar.coffee419
-rw-r--r--pages/vomnibar.css11
-rw-r--r--pages/vomnibar.html2
-rw-r--r--tests/dom_tests/vomnibar_test.coffee2
-rw-r--r--tests/unit_tests/completion_test.coffee52
-rw-r--r--tests/unit_tests/settings_test.coffee10
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 &lt;span&gt;") >= 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") }