diff options
Diffstat (limited to 'background_scripts/completion.coffee')
| -rw-r--r-- | background_scripts/completion.coffee | 419 |
1 files changed, 294 insertions, 125 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 |
