class Suggestion
showRelevancy: true # Set this to true to render relevancy when debugging the ranking scores.
# - type: one of [bookmark, history, tab].
constructor: (@queryTerms, @type, @url, @title, @computeRelevancyFunction, @extraRelevancyData) ->
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) ->
toReplace = {}
for term in @queryTerms
regexp = @escapeRegexp(term)
i = string.search(regexp)
toReplace[i] = term.length if i >= 0 && (!toReplace[i] || toReplace[i].length < term.length)
indices = []
indices.push([key, toReplace[key]]) for key of toReplace
indices.sort (a, b) -> b - a
for [i, length] in indices
i = +i # convert i from String to Integer.
string =
string.substr(0, i) +
"" + string.substr(i, length) + "" +
string.substr(i + length)
string
# Creates a Regexp from the given string, with all special Regexp characters escaped.
escapeRegexp: (string) ->
# Taken from http://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex
Suggestion.escapeRegExp ||= /[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g
new RegExp(string.replace(Suggestion.escapeRegExp, "\\$&"), "i")
computeRelevancy: -> @relevancy = @computeRelevancyFunction(@queryTerms, this)
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: (queryTerms, suggestion) ->
RankingUtils.wordRelevancy(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: (queryTerms, suggestion) ->
@oneMonthAgo ||= 1000 * 60 * 60 * 24 * 30
historyEntry = suggestion.extraRelevancyData
recency = Date.now() - historyEntry.lastVisitTime
recencyDifference = Math.max(0, @oneMonthAgo - recency) / @oneMonthAgo
# recencyScore is between [0, 1]. It is 1 when recenyDifference is 0. This qudratic equation will
# incresingly discount older history entries.
recencyScore = recencyDifference * recencyDifference * recencyDifference
wordRelevancy = RankingUtils.wordRelevancy(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: ->
class MultiCompleter
constructor: (@completers) ->
@maxResults = 10 # TODO(philc): Should this be configurable?
refresh: -> completer.refresh() for completer in @completers
filter: (queryTerms, onComplete) ->
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)
sortSuggestions: (suggestions) ->
for suggestion in suggestions
suggestion.computeRelevancy(@queryTerms)
suggestions.sort (a, b) -> a.relevancy - b.relevancy
suggestions
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
return false unless title.indexOf(term) >= 0 || url.indexOf(term) >= 0
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.indexOf(term) >= 0
urlScore = urlScore / queryTerms.length
urlScore = urlScore * RankingUtils.normalizeDifference(queryLength, url.length)
titleScore = titleScore / queryTerms.length
titleScore = titleScore * RankingUtils.normalizeDifference(queryLength, title.length)
(urlScore + titleScore) / 2
# 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
# Provides cached access to Chrome's history.
HistoryCache =
size: 20000
history: null # An array of History items returned from Chrome.
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) =>
# sorting in ascending order. We will push new items on to the end as the user navigates to new pages.
history.sort((a, b) -> (a.lastVisitTime || 0) - (b.lastVisitTime || 0))
@history = history
chrome.history.onVisited.addListener (newSite) =>
firstTimeVisit = (newSite.visitedCount == 1)
@history.push(newSite) if firstTimeVisit
callback(@history) for callback in @callbacks
@callbacks = null
root = exports ? window
root.Suggestion = Suggestion
root.BookmarkCompleter = BookmarkCompleter
root.MultiCompleter = MultiCompleter
root.HistoryCompleter = HistoryCompleter