# This file contains the definition of the completers used for the Vomnibox's suggestion UI. A completer will
# take a query (whatever the user typed into the Vomnibox) and return a list of Suggestions, e.g. bookmarks,
# domains, URLs from history.
#
# 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:
# - 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.
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 ||= ""
computeRelevancy: -> @relevancy = @computeRelevancyFunction(this)
generateHtml: ->
return @html if @html
relevancyHtml = if @showRelevancy then "#{@computeRelevancy() + ''}" else ""
@html =
"
#{@type}
#{@highlightTerms(Utils.escapeHtml(@title))}
#{@shortenUrl(@highlightTerms(@url))}
#{relevancyHtml}
"
shortenUrl: (url) -> @stripTrailingSlash(url).replace(/^http:\/\//, "")
stripTrailingSlash: (url) ->
url = url.substring(url, url.length - 1) if url[url.length - 1] == "/"
url
# Wraps each occurence of the query terms in the given string in a .
highlightTerms: (string) ->
ranges = []
for term in @queryTerms
i = string.search(RegexpCache.get(term))
ranges.push([i, i + term.length]) if i >= 0
return string if ranges.length == 0
ranges = @mergeRanges(ranges.sort (a, b) -> a[0] - b[0])
# Replace portions of the string from right to left.
ranges = ranges.sort (a, b) -> b[0] - a[0]
for [start, end] in ranges
string =
string.substring(0, start) +
"" + string.substring(start, end) + "" +
string.substring(end)
string
# Merges the given list of ranges such that any overlapping regions are combined. E.g.
# mergeRanges([0, 4], [3, 6]) => [0, 6]. A range is [startIndex, endIndex].
mergeRanges: (ranges) ->
previous = ranges.shift()
mergedRanges = [previous]
ranges.forEach (range) ->
if previous[1] >= range[0]
previous[1] = Math.max(range[1], previous[1])
else
mergedRanges.push(range)
previous = range
mergedRanges
class BookmarkCompleter
currentSearch: null
# These bookmarks are loaded asynchronously when refresh() is called.
bookmarks: null
filter: (@queryTerms, @onComplete) ->
@currentSearch = { queryTerms: @queryTerms, onComplete: @onComplete }
@performSearch() if @bookmarks
onBookmarksLoaded: -> @performSearch() if @currentSearch
performSearch: ->
results = @bookmarks.filter (bookmark) =>
RankingUtils.matches(@currentSearch.queryTerms, bookmark.url, bookmark.title)
suggestions = results.map (bookmark) =>
new Suggestion(@currentSearch.queryTerms, "bookmark", bookmark.url, bookmark.title, @computeRelevancy)
onComplete = @currentSearch.onComplete
@currentSearch = null
onComplete(suggestions)
refresh: ->
@bookmarks = null
chrome.bookmarks.getTree (bookmarks) =>
@bookmarks = @traverseBookmarks(bookmarks).filter((bookmark) -> bookmark.url?)
@onBookmarksLoaded()
# Traverses the bookmark hierarchy, and retuns a flattened list of all bookmarks in the tree.
traverseBookmarks: (bookmarks) ->
results = []
toVisit = bookmarks
while toVisit.length > 0
bookmark = toVisit.shift()
results.push(bookmark)
toVisit.push.apply(toVisit, bookmark.children) if (bookmark.children)
results
computeRelevancy: (suggestion) ->
RankingUtils.wordRelevancy(suggestion.queryTerms, suggestion.url, suggestion.title)
class HistoryCompleter
filter: (queryTerms, onComplete) ->
@currentSearch = { queryTerms: @queryTerms, onComplete: @onComplete }
results = []
HistoryCache.use (history) =>
results = history.filter (entry) -> RankingUtils.matches(queryTerms, entry.url, entry.title)
suggestions = results.map (entry) =>
new Suggestion(queryTerms, "history", entry.url, entry.title, @computeRelevancy, entry)
onComplete(suggestions)
computeRelevancy: (suggestion) ->
historyEntry = suggestion.extraRelevancyData
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: ->
# 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.
class DomainCompleter
domains: null # A map of domain -> history
filter: (queryTerms, onComplete) ->
return onComplete([]) if queryTerms.length > 1
if @domains
@performSearch(queryTerms, onComplete)
else
@populateDomains => @performSearch(queryTerms, onComplete)
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)])
# Returns a list of domains of the form: [ [domain, relevancy], ... ]
sortDomainsByRelevancy: (queryTerms, domainCandidates) ->
results = []
for domain in domainCandidates
recencyScore = RankingUtils.recencyScore(@domains[domain].lastVisitTime || 0)
wordRelevancy = RankingUtils.wordRelevancy(queryTerms, domain, null)
score = wordRelevancy + Math.max(recencyScore, wordRelevancy) / 2
results.push([domain, score])
results.sort (a, b) -> b[1] - a[1]
results
populateDomains: (onComplete) ->
HistoryCache.use (history) =>
@domains = {}
history.forEach (entry) =>
# We want each key in our domains hash to point to the most recent History entry for that domain.
domain = @parseDomain(entry.url)
if domain
previousEntry = @domains[domain]
@domains[domain] = entry if !previousEntry || (previousEntry.lastVisitTime < entry.lastVisitTime)
chrome.history.onVisited.addListener(@onPageVisited.bind(this))
onComplete()
onPageVisited: (newPage) ->
domain = @parseDomain(newPage.url)
@domains[domain] = newPage if domain
parseDomain: (url) -> url.split("/")[2] || ""
# Suggestions from the Domain completer have the maximum relevancy. They should be shown first in the list.
computeRelevancy: -> 1
# Searches through all open tabs, matching on title and URL.
class TabCompleter
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)
computeRelevancy: (suggestion) ->
RankingUtils.wordRelevancy(suggestion.queryTerms, suggestion.url, suggestion.title)
# 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
refresh: -> completer.refresh() for completer in @completers when completer.refresh
filter: (queryTerms, onComplete) ->
# Allow only one query to run at a time.
if @filterInProgress
@mostRecentQuery = { queryTerms: queryTerms, onComplete: onComplete }
return
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
suggestions.sort (a, b) -> b.relevancy - a.relevancy
suggestions
# Utilities which help us compute a relevancy score for a given item.
RankingUtils =
# Whether the given URL or title match any one of the query terms. This is used to prune out irrelevant
# suggestions before we try to rank them.
matches: (queryTerms, url, title) ->
return false if queryTerms.length == 0
for term in queryTerms
regexp = RegexpCache.get(term)
return false unless title.match(regexp) || url.match(regexp)
true
# Returns a number between [0, 1] indicating how often the query terms appear in the url and title.
wordRelevancy: (queryTerms, url, title) ->
queryLength = 0
urlScore = 0.0
titleScore = 0.0
for term in queryTerms
queryLength += term.length
urlScore += 1 if url.indexOf(term) >= 0
titleScore += 1 if title && title.indexOf(term) >= 0
urlScore = urlScore / queryTerms.length
urlScore = urlScore * RankingUtils.normalizeDifference(queryLength, url.length)
if title
titleScore = titleScore / queryTerms.length
titleScore = titleScore * RankingUtils.normalizeDifference(queryLength, title.length)
else
titleScore = urlScore
(urlScore + titleScore) / 2
# Returns a score between [0, 1] which indicates how recent the given timestamp is. Items which are over
# a month old are counted as 0. This range is quadratic, so an item from one day ago has a much stronger
# score than an item from two days ago.
recencyScore: (lastAccessedTime) ->
@oneMonthAgo ||= 1000 * 60 * 60 * 24 * 30
recency = Date.now() - lastAccessedTime
recencyDifference = Math.max(0, @oneMonthAgo - recency) / @oneMonthAgo
# recencyScore is between [0, 1]. It is 1 when recenyDifference is 0. This quadratic equation will
# incresingly discount older history entries.
recencyScore = recencyDifference * recencyDifference * recencyDifference
# Takes the difference of two numbers and returns a number between [0, 1] (the percentage difference).
normalizeDifference: (a, b) ->
max = Math.max(a, b)
(max - Math.abs(a - b)) / max
# We cache regexps because we use them frequently when comparing a query to history entries and bookmarks,
# and we don't want to create fresh objects for every comparison.
RegexpCache =
init: ->
@initialized = true
@clear()
# Taken from http://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex
@escapeRegExp ||= /[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g
clear: -> @cache = {}
get: (string) ->
@init() unless @initialized
@cache[string] ||= @escapeRegexp(string)
# Creates a Regexp from the given string, with all special Regexp characters escaped.
escapeRegexp: (string) -> new RegExp(string.replace(@escapeRegExp, "\\$&"), "i")
# Provides cached access to Chrome's history. As the user browses to new pages, we add those pages to this
# history cache.
HistoryCache =
size: 20000
history: null # An array of History items returned from Chrome.
reset: ->
@history = null
@callbacks = null
use: (callback) ->
return @fetchHistory(callback) unless @history?
callback(@history)
fetchHistory: (callback) ->
return @callbacks.push(callback) if @callbacks
@callbacks = [callback]
chrome.history.search { text: "", maxResults: @size, startTime: 0 }, (history) =>
history.sort @compareHistoryByUrl
@history = history
chrome.history.onVisited.addListener(@onPageVisited.bind(this))
callback(@history) for callback in @callbacks
@callbacks = null
compareHistoryByUrl: (a, b) ->
return 0 if a.url == b.url
return 1 if a.url > b.url
-1
# When a page we've seen before has been visited again, be sure to replace our History item so it has the
# correct "lastVisitTime". That's crucial for ranking Vomnibar suggestions.
onPageVisited: (newPage) ->
i = HistoryCache.binarySearch(newPage, @history, @compareHistoryByUrl)
pageWasFound = (@history[i].url == newPage.url)
if pageWasFound
@history[i] = newPage
else
@history.splice(i, 0, newPage)
# Returns the matching index or the closest matching index if the element is not found. That means you
# must check the element at the returned index to know whether the element was actually found.
# This method is used for quickly searching through our history cache.
HistoryCache.binarySearch = (targetElement, array, compareFunction) ->
high = array.length - 1
low = 0
while (low <= high)
middle = Math.floor((low + high) / 2)
element = array[middle]
compareResult = compareFunction(element, targetElement)
if (compareResult > 0)
high = middle - 1
else if (compareResult < 0)
low = middle + 1
else
return middle
# We didn't find the element. Return the position where it should be in this array.
return if compareFunction(element, targetElement) < 0 then middle + 1 else middle
root = exports ? window
root.Suggestion = Suggestion
root.BookmarkCompleter = BookmarkCompleter
root.MultiCompleter = MultiCompleter
root.HistoryCompleter = HistoryCompleter
root.DomainCompleter = DomainCompleter
root.TabCompleter = TabCompleter
root.HistoryCache = HistoryCache
root.RankingUtils = RankingUtils