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