aboutsummaryrefslogtreecommitdiffstats
path: root/background_scripts
diff options
context:
space:
mode:
Diffstat (limited to 'background_scripts')
-rw-r--r--background_scripts/commands.coffee97
-rw-r--r--background_scripts/completion.coffee477
-rw-r--r--background_scripts/completion_engines.coffee135
-rw-r--r--background_scripts/completion_search.coffee139
-rw-r--r--background_scripts/exclusions.coffee13
-rw-r--r--background_scripts/main.coffee363
-rw-r--r--background_scripts/settings.coffee133
-rw-r--r--background_scripts/sync.coffee74
8 files changed, 903 insertions, 528 deletions
diff --git a/background_scripts/commands.coffee b/background_scripts/commands.coffee
index 79cb9ee0..64ec36be 100644
--- a/background_scripts/commands.coffee
+++ b/background_scripts/commands.coffee
@@ -24,21 +24,13 @@ Commands =
noRepeat: options.noRepeat
repeatLimit: options.repeatLimit
- mapKeyToCommand: (key, command) ->
+ mapKeyToCommand: ({ key, command, options }) ->
unless @availableCommands[command]
- console.log(command, "doesn't exist!")
+ console.log command, "doesn't exist!"
return
- commandDetails = @availableCommands[command]
-
- @keyToCommandRegistry[key] =
- command: command
- isBackgroundCommand: commandDetails.isBackgroundCommand
- passCountToFunction: commandDetails.passCountToFunction
- noRepeat: commandDetails.noRepeat
- repeatLimit: commandDetails.repeatLimit
-
- unmapKey: (key) -> delete @keyToCommandRegistry[key]
+ options ?= []
+ @keyToCommandRegistry[key] = extend { command, options }, @availableCommands[command]
# Lower-case the appropriate portions of named keys.
#
@@ -52,39 +44,32 @@ Commands =
key.replace(/<[acm]-/ig, (match) -> match.toLowerCase())
.replace(/<([acm]-)?([a-zA-Z0-9]{2,5})>/g, (match, optionalPrefix, keyName) ->
"<" + (if optionalPrefix then optionalPrefix else "") + keyName.toLowerCase() + ">")
+ .replace /<space>/ig, " "
parseCustomKeyMappings: (customKeyMappings) ->
- lines = customKeyMappings.split("\n")
-
- for line in lines
- continue if (line[0] == "\"" || line[0] == "#")
- splitLine = line.replace(/\s+$/, "").split(/\s+/)
-
- lineCommand = splitLine[0]
-
- if (lineCommand == "map")
- continue if (splitLine.length != 3)
- key = @normalizeKey(splitLine[1])
- vimiumCommand = splitLine[2]
-
- continue unless @availableCommands[vimiumCommand]
-
- console.log("Mapping", key, "to", vimiumCommand)
- @mapKeyToCommand(key, vimiumCommand)
- else if (lineCommand == "unmap")
- continue if (splitLine.length != 2)
-
- key = @normalizeKey(splitLine[1])
- console.log("Unmapping", key)
- @unmapKey(key)
- else if (lineCommand == "unmapAll")
- @keyToCommandRegistry = {}
+ for line in customKeyMappings.split "\n"
+ unless line[0] == "\"" or line[0] == "#"
+ tokens = line.replace(/\s+$/, "").split /\s+/
+ switch tokens[0]
+ when "map"
+ [ _, key, command, options... ] = tokens
+ if command? and @availableCommands[command]
+ key = @normalizeKey key
+ console.log "Mapping", key, "to", command
+ @mapKeyToCommand { key, command, options }
+
+ when "unmap"
+ if tokens.length == 2
+ key = @normalizeKey tokens[1]
+ console.log "Unmapping", key
+ delete @keyToCommandRegistry[key]
+
+ when "unmapAll"
+ @keyToCommandRegistry = {}
clearKeyMappingsAndSetDefaults: ->
@keyToCommandRegistry = {}
-
- for key of defaultKeyMappings
- @mapKeyToCommand(key, defaultKeyMappings[key])
+ @mapKeyToCommand { key, command } for key, command of defaultKeyMappings
# An ordered listing of all available commands, grouped by type. This is the order they will
# be shown in the help page.
@@ -121,18 +106,20 @@ Commands =
"LinkHints.activateModeWithQueue",
"LinkHints.activateModeToDownloadLink",
"LinkHints.activateModeToOpenIncognito",
- "Vomnibar.activate",
- "Vomnibar.activateInNewTab",
- "Vomnibar.activateTabSelection",
- "Vomnibar.activateBookmarks",
- "Vomnibar.activateBookmarksInNewTab",
"goPrevious",
"goNext",
"nextFrame",
+ "mainFrame",
"Marks.activateCreateMode",
- "Vomnibar.activateEditUrl",
- "Vomnibar.activateEditUrlInNewTab",
"Marks.activateGotoMode"]
+ vomnibarCommands:
+ ["Vomnibar.activate",
+ "Vomnibar.activateInNewTab",
+ "Vomnibar.activateTabSelection",
+ "Vomnibar.activateBookmarks",
+ "Vomnibar.activateBookmarksInNewTab",
+ "Vomnibar.activateEditUrl",
+ "Vomnibar.activateEditUrlInNewTab"]
findCommands: ["enterFindMode", "performFind", "performBackwardsFind"]
historyNavigation:
["goBack", "goForward"]
@@ -255,6 +242,7 @@ defaultKeyMappings =
"gE": "Vomnibar.activateEditUrlInNewTab"
"gf": "nextFrame"
+ "gF": "mainFrame"
"m": "Marks.activateCreateMode"
"`": "Marks.activateGotoMode"
@@ -289,8 +277,8 @@ commandDescriptions =
openCopiedUrlInNewTab: ["Open the clipboard's URL in a new tab", { background: true, repeatLimit: 20 }]
enterInsertMode: ["Enter insert mode", { noRepeat: true }]
- enterVisualMode: ["Enter visual mode (not yet implemented)", { noRepeat: true }]
- enterVisualLineMode: ["Enter visual line mode (not yet implemented)", { noRepeat: true }]
+ enterVisualMode: ["Enter visual mode (beta feature)", { noRepeat: true }]
+ enterVisualLineMode: ["Enter visual line mode (beta feature)", { noRepeat: true }]
# enterEditMode: ["Enter vim-like edit mode (not yet implemented)", { noRepeat: true }]
focusInput: ["Focus the first text box on the page. Cycle between them using tab",
@@ -319,8 +307,8 @@ commandDescriptions =
goToRoot: ["Go to root of current URL hierarchy", { passCountToFunction: true }]
# Manipulating tabs
- nextTab: ["Go one tab right", { background: true }]
- previousTab: ["Go one tab left", { background: true }]
+ nextTab: ["Go one tab right", { background: true, passCountToFunction: true }]
+ previousTab: ["Go one tab left", { background: true, passCountToFunction: true }]
firstTab: ["Go to the first tab", { background: true }]
lastTab: ["Go to the last tab", { background: true }]
@@ -350,11 +338,18 @@ commandDescriptions =
"Vomnibar.activateEditUrlInNewTab": ["Edit the current URL and open in a new tab", { noRepeat: true }]
nextFrame: ["Cycle forward to the next frame on the page", { background: true, passCountToFunction: true }]
+ mainFrame: ["Select the tab's main/top frame", { background: true, noRepeat: true }]
"Marks.activateCreateMode": ["Create a new mark", { noRepeat: true }]
"Marks.activateGotoMode": ["Go to a mark", { noRepeat: true }]
Commands.init()
+# Register postUpdateHook for keyMappings setting.
+Settings.postUpdateHooks["keyMappings"] = (value) ->
+ Commands.clearKeyMappingsAndSetDefaults()
+ Commands.parseCustomKeyMappings value
+ refreshCompletionKeysAfterMappingSave()
+
root = exports ? window
root.Commands = Commands
diff --git a/background_scripts/completion.coffee b/background_scripts/completion.coffee
index 177892fb..c83066a6 100644
--- a/background_scripts/completion.coffee
+++ b/background_scripts/completion.coffee
@@ -5,42 +5,71 @@
# 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.
+ 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. This only
+ # affects the suggestion in slot 0 in the vomnibar.
@autoSelect = false
+ # If @highlightTerms is true, then we highlight matched terms in the title and URL. Otherwise we don't.
+ @highlightTerms = true
+ # @insertText is text to insert into the vomnibar input when the suggestion is selected.
+ @insertText = null
+ # @deDuplicate controls whether this suggestion is a candidate for deduplication.
+ @deDuplicate = true
+
+ # Other options set by individual completers include:
+ # - tabId (TabCompleter)
+ # - isSearchSuggestion, customSearchMode (SearchEngineCompleter)
+
+ extend this, @options
- computeRelevancy: -> @relevancy = @computeRelevancyFunction(this)
+ 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: ->
+ generateHtml: (request) ->
return @html if @html
relevancyHtml = if @showRelevancy then "<span class='relevancy'>#{@computeRelevancy()}</span>" else ""
+ insertTextClass = if @insertText then "vomnibarInsertText" else "vomnibarNoInsertText"
+ insertTextIndicator = "&#8618;" # A right hooked arrow.
+ @title = @insertText if @insertText and request.isCustomSearch
# NOTE(philc): We're using these vimium-specific class names so we don't collide with the page's CSS.
@html =
- """
- <div class="vimiumReset vomnibarTopHalf">
- <span class="vimiumReset vomnibarSource">#{@type}</span>
- <span class="vimiumReset vomnibarTitle">#{@highlightTerms(Utils.escapeHtml(@title))}</span>
- </div>
- <div class="vimiumReset vomnibarBottomHalf">
- <span class="vimiumReset vomnibarUrl">#{@shortenUrl(@highlightTerms(Utils.escapeHtml(@url)))}</span>
- #{relevancyHtml}
- </div>
- """
+ if request.isCustomSearch
+ """
+ <div class="vimiumReset vomnibarTopHalf">
+ <span class="vimiumReset vomnibarSource #{insertTextClass}">#{insertTextIndicator}</span><span class="vimiumReset vomnibarSource">#{@type}</span>
+ <span class="vimiumReset vomnibarTitle">#{@highlightQueryTerms Utils.escapeHtml @title}</span>
+ #{relevancyHtml}
+ </div>
+ """
+ else
+ """
+ <div class="vimiumReset vomnibarTopHalf">
+ <span class="vimiumReset vomnibarSource #{insertTextClass}">#{insertTextIndicator}</span><span class="vimiumReset vomnibarSource">#{@type}</span>
+ <span class="vimiumReset vomnibarTitle">#{@highlightQueryTerms Utils.escapeHtml @title}</span>
+ </div>
+ <div class="vimiumReset vomnibarBottomHalf">
+ <span class="vimiumReset vomnibarSource vomnibarNoInsertText">#{insertTextIndicator}</span><span class="vimiumReset vomnibarUrl">#{@highlightUrlTerms Utils.escapeHtml @shortenUrl()}</span>
+ #{relevancyHtml}
+ </div>
+ """
# Use neat trick to snatch a domain (http://stackoverflow.com/a/8498668).
getUrlRoot: (url) ->
@@ -48,7 +77,10 @@ class Suggestion
a.href = url
a.protocol + "//" + a.hostname
- shortenUrl: (url) -> @stripTrailingSlash(url).replace(/^https?:\/\//, "")
+ getHostname: (url) ->
+ a = document.createElement 'a'
+ a.href = url
+ a.hostname
stripTrailingSlash: (url) ->
url = url.substring(url, url.length - 1) if url[url.length - 1] == "/"
@@ -77,7 +109,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
@@ -95,6 +128,9 @@ class Suggestion
string.substring(end)
string
+ highlightUrlTerms: (string) ->
+ if @highlightTermsExcludeUrl then string else @highlightQueryTerms 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) ->
@@ -108,6 +144,41 @@ class Suggestion
previous = range
mergedRanges
+ # Simplify a suggestion's URL (by removing those parts which aren't useful for display or comparison).
+ shortenUrl: () ->
+ return @shortUrl if @shortUrl?
+ # We get easier-to-read shortened URLs if we URI-decode them.
+ url = (Utils.decodeURIByParts(@url) || @url).toLowerCase()
+ for [ filter, replacements ] in @stripPatterns
+ if new RegExp(filter).test url
+ for replace in replacements
+ url = url.replace replace, ""
+ @shortUrl = url
+
+ # Patterns to strip from URLs; of the form [ [ filter, replacements ], [ filter, replacements ], ... ]
+ # - filter is a regexp string; a URL must match this regexp first.
+ # - replacements (itself a list) is a list of regexp objects, each of which is removed from URLs matching
+ # the filter.
+ #
+ # Note. This includes site-specific patterns for very-popular sites with URLs which don't work well in the
+ # vomnibar.
+ #
+ stripPatterns: [
+ # Google search specific replacements; this replaces query parameters which are known to not be helpful.
+ # There's some additional information here: http://www.teknoids.net/content/google-search-parameters-2012
+ [ "^https?://www\.google\.(com|ca|com\.au|co\.uk|ie)/.*[&?]q="
+ "ei gws_rd url ved usg sa usg sig2 bih biw cd aqs ie sourceid es_sm"
+ .split(/\s+/).map (param) -> new RegExp "\&#{param}=[^&]+" ]
+
+ # General replacements; replaces leading and trailing fluff.
+ [ '.', [ "^https?://", "\\W+$" ].map (re) -> new RegExp re ]
+ ]
+
+ # Boost a relevancy score by a factor (in the range (0,1.0)), while keeping the score in the range [0,1].
+ # This makes greater adjustments to scores near the middle of the range (so, very poor relevancy scores
+ # remain very poor).
+ @boostRelevancyScore: (factor, score) ->
+ score + if score < 0.5 then score * factor else (1.0 - score) * factor
class BookmarkCompleter
folderSeparator: "/"
@@ -115,7 +186,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 +204,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,27 +247,35 @@ class BookmarkCompleter
RankingUtils.wordRelevancy(suggestion.queryTerms, suggestion.url, suggestion.title)
class HistoryCompleter
- filter: (queryTerms, onComplete) ->
+ filter: ({ queryTerms, seenTabToOpenCompletionList }, onComplete) ->
@currentSearch = { queryTerms: @queryTerms, onComplete: @onComplete }
results = []
HistoryCache.use (history) =>
results =
if queryTerms.length > 0
history.filter (entry) -> RankingUtils.matches(queryTerms, entry.url, entry.title)
+ else if seenTabToOpenCompletionList
+ # <Tab> opens the completion list, even without a query.
+ history
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)
+ # If there are no query terms, then relevancy is based on recency alone.
+ return recencyScore if suggestion.queryTerms.length == 0
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 +286,9 @@ class DomainCompleter
# If `referenceCount` goes to zero, the domain entry can and should be deleted.
domains: null
- filter: (queryTerms, onComplete) ->
- return onComplete([]) if queryTerms.length > 1
+ filter: ({ queryTerms, query }, onComplete) ->
+ # Do not offer completions if the query is empty, or if the user has finished typing the first word.
+ return onComplete [] if queryTerms.length == 0 or /\S\s/.test query
if @domains
@performSearch(queryTerms, onComplete)
else
@@ -212,20 +296,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: 2.0
+ ].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 +346,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 +389,21 @@ 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
+ deDuplicate: false
+ onComplete suggestions
computeRelevancy: (suggestion) ->
if suggestion.queryTerms.length
@@ -321,66 +411,232 @@ 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
- suggestions = []
- if url
- url = url.replace(/%s/g, Utils.createSearchQuery queryTerms[1..])
- if description
- type = description
- query = queryTerms[1..].join " "
+ @debug: false
+ previousSuggestions: null
+
+ cancel: ->
+ CompletionSearch.cancel()
+
+ # This looks up the custom search engine and, if one is found, notes it and removes its keyword from the
+ # query terms.
+ preprocessRequest: (request) ->
+ SearchEngines.use (engines) =>
+ { queryTerms, query } = request
+ extend request, searchEngines: engines, keywords: key for own key of engines
+ keyword = queryTerms[0]
+ # Note. For a keyword "w", we match "w search terms" and "w ", but not "w" on its own.
+ if keyword and engines[keyword] and (1 < queryTerms.length or /\S\s/.test query)
+ extend request,
+ queryTerms: queryTerms[1..]
+ keyword: keyword
+ engine: engines[keyword]
+ isCustomSearch: true
+
+ refresh: (port) ->
+ @previousSuggestions = {}
+ SearchEngines.refreshAndUse Settings.get("searchEngines"), (engines) ->
+ # Let the front-end vomnibar know the search-engine keywords. It needs to know them so that, when the
+ # query goes from "w" to "w ", the vomnibar can synchronously launch the next filter() request (which
+ # avoids an ugly delay/flicker).
+ port.postMessage
+ handler: "keywords"
+ keywords: key for own key of engines
+
+ filter: (request, onComplete) ->
+ { queryTerms, query, engine } = request
+ return onComplete [] unless engine
+
+ { keyword, searchUrl, description } = engine
+ extend request, searchUrl, customSearchMode: true
+
+ factor = 0.5
+ haveCompletionEngine = CompletionSearch.haveCompletionEngine searchUrl
+
+ # This filter is applied to all of the suggestions from all of the completers, after they have been
+ # aggregated by the MultiCompleter.
+ filter = (suggestions) ->
+ suggestions.filter (suggestion) ->
+ # We only keep suggestions which either *were* generated by this search engine, or *could have
+ # been* generated by this search engine (and match the current query).
+ suggestion.isSearchSuggestion or suggestion.isCustomSearch or
+ (
+ terms = Utils.extractQuery searchUrl, suggestion.url
+ terms and RankingUtils.matches queryTerms, terms
+ )
+
+ # If a previous suggestion still matches the query, then we keep it (even if the completion engine may not
+ # return it for the current query). This allows the user to pick suggestions by typing fragments of their
+ # text, without regard to whether the completion engine can complete the actual text of the query.
+ previousSuggestions =
+ if queryTerms.length == 0
+ []
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: ->
- this.searchEngines = root.Settings.getSearchEngines()
-
- getSearchEngineMatches: (queryTerms) ->
- (1 < queryTerms.length and @searchEngines[queryTerms[0]]) or {}
+ for url, suggestion of @previousSuggestions
+ continue unless RankingUtils.matches queryTerms, suggestion.title
+ # Reset various fields, they may not be correct wrt. the current query.
+ extend suggestion, relevancy: null, html: null, queryTerms: queryTerms
+ suggestion.relevancy = null
+ suggestion
+
+ primarySuggestion = new Suggestion
+ queryTerms: queryTerms
+ type: description
+ url: Utils.createSearchUrl queryTerms, searchUrl
+ title: queryTerms.join " "
+ relevancy: 2.0
+ autoSelect: true
+ highlightTerms: false
+ isSearchSuggestion: true
+ isPrimarySuggestion: true
+
+ return onComplete [ primarySuggestion ], { filter } if queryTerms.length == 0
+
+ mkSuggestion = do =>
+ count = 0
+ (suggestion) =>
+ url = Utils.createSearchUrl suggestion, searchUrl
+ @previousSuggestions[url] = new Suggestion
+ queryTerms: queryTerms
+ type: description
+ url: url
+ title: suggestion
+ insertText: suggestion
+ highlightTerms: false
+ highlightTermsExcludeUrl: true
+ isCustomSearch: true
+ relevancy: if ++count == 1 then 1.0 else null
+ relevancyFunction: @computeRelevancy
+
+ cachedSuggestions =
+ if haveCompletionEngine then CompletionSearch.complete searchUrl, queryTerms else null
+
+ suggestions = previousSuggestions
+ suggestions.push primarySuggestion
+
+ if queryTerms.length == 0 or cachedSuggestions? or not haveCompletionEngine
+ # There is no prospect of adding further completions, so we're done.
+ suggestions.push cachedSuggestions.map(mkSuggestion)... if cachedSuggestions?
+ onComplete suggestions, { filter, continuation: null }
+ else
+ # Post the initial suggestions, but then deliver any further completions asynchronously, as a
+ # continuation.
+ 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
+
+ computeRelevancy: ({ relevancyData, queryTerms, title }) ->
+ # Tweaks:
+ # - Calibration: we boost relevancy scores to try to achieve an appropriate balance between relevancy
+ # scores here, and those provided by other completers.
+ # - Relevancy depends only on the title (which is the search terms), and not on the URL.
+ Suggestion.boostRelevancyScore 0.5,
+ 0.7 * RankingUtils.wordRelevancy queryTerms, title, title
+
+ postProcessSuggestions: (request, suggestions) ->
+ return unless request.searchEngines
+ engines = (engine for _, engine of request.searchEngines)
+ engines.sort (a,b) -> b.searchUrl.length - a.searchUrl.length
+ engines.push keyword: null, description: "search history", searchUrl: Settings.get "searchUrl"
+ for suggestion in suggestions
+ unless suggestion.isSearchSuggestion or suggestion.insertText
+ for engine in engines
+ if suggestion.insertText = Utils.extractQuery engine.searchUrl, suggestion.url
+ # suggestion.customSearchMode informs the vomnibar that, if the users edits the text from this
+ # suggestion, then custom search-engine mode should be activated.
+ suggestion.customSearchMode = engine.keyword
+ suggestion.title ||= suggestion.insertText
+ break
# 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.
+# 10. All queries from the vomnibar come through a multi completer.
class MultiCompleter
- constructor: (@completers) -> @maxResults = 10
+ maxResults: 10
+ filterInProgress: false
+ mostRecentQuery: null
- 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.
+ completer.preprocessRequest? request for completer in @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 a
+ # pending query.
+ jobs.onReady =>
+ suggestions = filter suggestions 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 request, queryTerms, suggestions
+ onComplete results: suggestions
+
+ # 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 = filter suggestions for filter in filters
+ suggestions = @prepareSuggestions request, queryTerms, suggestions
+ onComplete results: suggestions
+
+ # Admit subsequent queries and launch any pending query.
+ @filterInProgress = false
+ if @mostRecentQuery
+ @filter @mostRecentQuery...
+
+ prepareSuggestions: (request, queryTerms, suggestions) ->
+ # Compute suggestion relevancies and sort.
+ suggestion.computeRelevancy queryTerms for suggestion in suggestions
suggestions.sort (a, b) -> b.relevancy - a.relevancy
+
+ # Simplify URLs and remove duplicates (duplicate simplified URLs, that is).
+ count = 0
+ seenUrls = {}
+ suggestions =
+ for suggestion in suggestions
+ url = suggestion.shortenUrl()
+ continue if suggestion.deDuplicate and seenUrls[url]
+ break if count++ == @maxResults
+ seenUrls[url] = suggestion
+
+ # Give each completer the opportunity to tweak the suggestions.
+ completer.postProcessSuggestions? request, suggestions for completer in @completers
+
+ # Generate HTML for the remaining suggestions and return them.
+ suggestion.generateHtml request for suggestion in suggestions
suggestions
# Utilities which help us compute a relevancy score for a given item.
@@ -529,8 +785,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..f15e6db4
--- /dev/null
+++ b/background_scripts/completion_engines.coffee
@@ -0,0 +1,135 @@
+
+# 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 URL.
+#
+# 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 added 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: (args...) -> @regexps = args.map (regexp) -> new RegExp regexp
+ match: (searchUrl) -> Utils.matchesAnyRegexp @regexps, searchUrl
+
+# Several Google completion engines package XML responses in this way.
+class GoogleXMLRegexpEngine extends RegexpEngine
+ 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: (regexps = null) ->
+ super regexps ? "^https?://[a-z]+\.google\.(com|ie|co\.uk|ca|com\.au)/"
+
+ getUrl: (queryTerms) ->
+ Utils.createSearchUrl queryTerms,
+ "http://suggestqueries.google.com/complete/search?ss_protocol=legace&client=toolbar&q=%s"
+
+# A wrapper class for Google completions. This adds prefix terms to the query, and strips those terms from
+# the resulting suggestions. For example, for Google Maps, we add "map of" as a prefix, then strip "map of"
+# from the resulting suggestions.
+class GoogleWithPrefix
+ constructor: (prefix, args...) ->
+ @engine = new Google args...
+ @prefix = "#{prefix.trim()} "
+ @queryTerms = @prefix.split /\s+/
+ match: (args...) -> @engine.match args...
+ getUrl: (queryTerms) -> @engine.getUrl [ @queryTerms..., queryTerms... ]
+ parse: (xhr) ->
+ @engine.parse(xhr)
+ .filter (suggestion) => suggestion.startsWith @prefix
+ .map (suggestion) => suggestion[@prefix.length..].ltrim()
+
+# For Google Maps, we add the prefix "map of" to the query, and send it to Google's general search engine,
+# then strip "map of" from the resulting suggestions.
+class GoogleMaps extends GoogleWithPrefix
+ # Example search URL: https://www.google.com/maps?q=%s
+ constructor: -> super "map of", "https?://[a-z]+\.google\.(com|ie|co\.uk|ca|com\.au)/maps"
+
+class Youtube extends GoogleXMLRegexpEngine
+ # Example search URL: http://www.youtube.com/results?search_query=%s
+ constructor: ->
+ super "^https?://[a-z]+\.youtube\.com/results"
+
+ getUrl: (queryTerms) ->
+ Utils.createSearchUrl queryTerms,
+ "http://suggestqueries.google.com/complete/search?client=youtube&ds=yt&xml=t&q=%s"
+
+class Wikipedia extends RegexpEngine
+ # Example search URL: http://www.wikipedia.org/w/index.php?title=Special:Search&search=%s
+ constructor: ->
+ super "^https?://[a-z]+\.wikipedia\.org/"
+
+ getUrl: (queryTerms) ->
+ Utils.createSearchUrl queryTerms,
+ "https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search=%s"
+
+ parse: (xhr) ->
+ JSON.parse(xhr.responseText)[1]
+
+class Bing extends RegexpEngine
+ # Example search URL: https://www.bing.com/search?q=%s
+ constructor: -> super "^https?://www\.bing\.com/search"
+ getUrl: (queryTerms) -> Utils.createSearchUrl queryTerms, "http://api.bing.com/osjson.aspx?query=%s"
+ parse: (xhr) -> JSON.parse(xhr.responseText)[1]
+
+class Amazon extends RegexpEngine
+ # Example search URL: http://www.amazon.com/s/?field-keywords=%s
+ constructor: -> super "^https?://www\.amazon\.(com|co.uk|ca|com.au)/s/"
+ getUrl: (queryTerms) ->
+ Utils.createSearchUrl queryTerms,
+ "https://completion.amazon.com/search/complete?method=completion&search-alias=aps&client=amazon-search-ui&mkt=1&q=%s"
+ parse: (xhr) -> JSON.parse(xhr.responseText)[1]
+
+class DuckDuckGo extends RegexpEngine
+ # Example search URL: https://duckduckgo.com/?q=%s
+ constructor: -> super "^https?://([a-z]+\.)?duckduckgo\.com/"
+ getUrl: (queryTerms) -> Utils.createSearchUrl queryTerms, "https://duckduckgo.com/ac/?q=%s"
+ parse: (xhr) ->
+ suggestion.phrase for suggestion in JSON.parse xhr.responseText
+
+class Webster extends RegexpEngine
+ # Example search URL: http://www.merriam-webster.com/dictionary/%s
+ constructor: -> super "^https?://www.merriam-webster.com/dictionary/"
+ getUrl: (queryTerms) -> Utils.createSearchUrl queryTerms, "http://www.merriam-webster.com/autocomplete?query=%s"
+ parse: (xhr) -> JSON.parse(xhr.responseText).suggestions
+
+# 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
+ GoogleMaps
+ Google
+ DuckDuckGo
+ Wikipedia
+ Bing
+ Amazon
+ Webster
+ 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..d89eb278
--- /dev/null
+++ b/background_scripts/completion_search.coffee
@@ -0,0 +1,139 @@
+
+CompletionSearch =
+ debug: false
+ inTransit: {}
+ completionCache: new SimpleCache 2 * 60 * 60 * 1000, 5000 # Two hours, 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 custom 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
+ # sometimes 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.
+ # Note: @mostRecentSuggestions may also be empty, in which case we drop though. The effect is that
+ # previous queries with no suggestions suppress subsequent no-hope HTTP requests as the user continues
+ # to type.
+ for suggestion in @mostRecentSuggestions
+ return false unless 0 <= suggestion.indexOf query
+ # Ok. Re-use the suggestion.
+ true
+
+ if reusePreviousSuggestions
+ console.log "reuse previous query:", @mostRecentQuery, @mostRecentSuggestions.length if @debug
+ return callback @completionCache.set completionCacheKey, @mostRecentSuggestions
+
+ # That's all of the caches we can try. Bail if the caller is only requesting synchronous results. We
+ # signal that we haven't found a match by returning null.
+ 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
+ # Make all suggestions lower case. It looks odd when suggestions from one completion engine are
+ # upper case, and those from another are lower case.
+ suggestions = (suggestion.toLowerCase() for suggestion in suggestions)
+ # Filter out the query itself. It's not adding anything.
+ suggestions = (suggestion for suggestion in suggestions when suggestion != 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
+ delete @inTransit[completionCacheKey]
+
+ # ... then use the suggestions.
+ @inTransit[completionCacheKey].use (suggestions) =>
+ @mostRecentQuery = query
+ @mostRecentSuggestions = suggestions
+ callback @completionCache.set completionCacheKey, suggestions
+
+ # 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/exclusions.coffee b/background_scripts/exclusions.coffee
index 55ced3ef..21342d61 100644
--- a/background_scripts/exclusions.coffee
+++ b/background_scripts/exclusions.coffee
@@ -2,6 +2,7 @@ root = exports ? window
RegexpCache =
cache: {}
+ clear: -> @cache = {}
get: (pattern) ->
if regexp = @cache[pattern]
regexp
@@ -31,9 +32,11 @@ root.Exclusions = Exclusions =
# An absolute exclusion rule (with no passKeys) takes priority.
for rule in matches
return rule unless rule.passKeys
+ # Strip whitespace from all matching passKeys strings, and join them together.
+ passKeys = (rule.passKeys.split(/\s+/).join "" for rule in matches).join ""
if 0 < matches.length
pattern: (rule.pattern for rule in matches).join " | " # Not used; for debugging only.
- passKeys: Utils.distinctCharacters (rule.passKeys for rule in matches).join ""
+ passKeys: Utils.distinctCharacters passKeys
else
null
@@ -42,8 +45,8 @@ root.Exclusions = Exclusions =
@rules = rules.filter (rule) -> rule and rule.pattern
Settings.set("exclusionRules", @rules)
- postUpdateHook: (rules) ->
- @rules = rules
+ postUpdateHook: (@rules) ->
+ RegexpCache.clear()
# Development and debug only.
# Enable this (temporarily) to restore legacy exclusion rules from backup.
@@ -70,3 +73,7 @@ if not Settings.has("exclusionRules") and Settings.has("excludedUrls")
# We'll keep a backup of the "excludedUrls" setting, just in case.
Settings.set("excludedUrlsBackup", Settings.get("excludedUrls")) if not Settings.has("excludedUrlsBackup")
Settings.clear("excludedUrls")
+
+# Register postUpdateHook for exclusionRules setting.
+Settings.postUpdateHooks["exclusionRules"] = (value) ->
+ Exclusions.postUpdateHook value
diff --git a/background_scripts/main.coffee b/background_scripts/main.coffee
index d034ffb0..99a5672b 100644
--- a/background_scripts/main.coffee
+++ b/background_scripts/main.coffee
@@ -1,7 +1,24 @@
root = exports ? window
-currentVersion = Utils.getCurrentVersion()
+# The browser may have tabs already open. We inject the content scripts immediately so that they work straight
+# away.
+chrome.runtime.onInstalled.addListener ({ reason }) ->
+ # See https://developer.chrome.com/extensions/runtime#event-onInstalled
+ return if reason in [ "chrome_update", "shared_module_update" ]
+ manifest = chrome.runtime.getManifest()
+ # Content scripts loaded on every page should be in the same group. We assume it is the first.
+ contentScripts = manifest.content_scripts[0]
+ jobs = [ [ chrome.tabs.executeScript, contentScripts.js ], [ chrome.tabs.insertCSS, contentScripts.css ] ]
+ # Chrome complains if we don't evaluate chrome.runtime.lastError on errors (and we get errors for tabs on
+ # which Vimium cannot run).
+ checkLastRuntimeError = -> chrome.runtime.lastError
+ chrome.tabs.query { status: "complete" }, (tabs) ->
+ for tab in tabs
+ for [ func, files ] in jobs
+ for file in files
+ func tab.id, { file: file, allFrames: contentScripts.all_frames }, checkLastRuntimeError
+currentVersion = Utils.getCurrentVersion()
tabQueue = {} # windowId -> Array
tabInfoMap = {} # tabId -> object with various tab properties
keyQueue = "" # Queue of keys typed
@@ -9,6 +26,7 @@ validFirstKeys = {}
singleKeyCommands = []
focusedFrame = null
frameIdsForTab = {}
+root.urlForTab = {}
# Keys are either literal characters, or "named" - for example <a-b> (alt+b), <left> (left arrow) or <f12>
# This regular expression captures two groups: the first is a named key, the second is the remainder of
@@ -25,22 +43,38 @@ 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])
-
-chrome.runtime.onConnect.addListener((port, name) ->
+ 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 is done asynchronously).
+ 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
# If this is a tab we've been waiting to open, execute any "tab loaded" handlers, e.g. to restore
# the tab's scroll position. Wait until domReady before doing this; otherwise operations like restoring
@@ -52,14 +86,8 @@ chrome.runtime.onConnect.addListener((port, name) ->
delete tabLoadedHandlers[senderTabId]
toCall.call()
- # domReady is the appropriate time to show the "vimium has been upgraded" message.
- # TODO: This might be broken on pages with frames.
- if (shouldShowUpgradeMessage())
- chrome.tabs.sendMessage(senderTabId, { name: "showUpgradeNotification", version: currentVersion })
-
if (portHandlers[port.name])
port.onMessage.addListener(portHandlers[port.name])
-)
chrome.runtime.onMessage.addListener((request, sender, sendResponse) ->
if (sendRequestHandlers[request.handler])
@@ -76,14 +104,24 @@ getCurrentTabUrl = (request, sender) -> sender.tab.url
#
# Checks the user's preferences in local storage to determine if Vimium is enabled for the given URL, and
# whether any keys should be passed through to the underlying page.
+# The source frame also informs us whether or not it has the focus, which allows us to track the URL of the
+# active frame.
#
-root.isEnabledForUrl = isEnabledForUrl = (request) ->
+root.isEnabledForUrl = isEnabledForUrl = (request, sender) ->
+ urlForTab[sender.tab.id] = request.url if request.frameIsFocused
rule = Exclusions.getRule(request.url)
{
isEnabledForUrl: not rule or rule.passKeys
passKeys: rule?.passKeys or ""
}
+onURLChange = (details) ->
+ chrome.tabs.sendMessage details.tabId, name: "checkEnabledAfterURLChange"
+
+# Re-check whether Vimium is enabled for a frame when the url changes without a reload.
+chrome.webNavigation.onHistoryStateUpdated.addListener onURLChange # history.pushState.
+chrome.webNavigation.onReferenceFragmentUpdated.addListener onURLChange # Hash changed.
+
# Retrieves the help dialog HTML template from a file, and populates it with the latest keybindings.
# This is called by options.coffee.
root.helpDialogHtml = (showUnboundCommands, showCommandNames, customTitle) ->
@@ -149,22 +187,22 @@ openUrlInCurrentTab = (request) ->
#
# Opens request.url in new tab and switches to it if request.selected is true.
#
-openUrlInNewTab = (request) ->
- chrome.tabs.getSelected(null, (tab) ->
- chrome.tabs.create({ url: Utils.convertToUrl(request.url), index: tab.index + 1, selected: true }))
+openUrlInNewTab = (request, callback) ->
+ chrome.tabs.getSelected null, (tab) ->
+ tabConfig =
+ url: Utils.convertToUrl request.url
+ index: tab.index + 1
+ selected: true
+ windowId: tab.windowId
+ # FIXME(smblott). openUrlInNewTab is being called in two different ways with different arguments. We
+ # should refactor it such that this check on callback isn't necessary.
+ callback = (->) unless typeof callback == "function"
+ chrome.tabs.create tabConfig, callback
openUrlInIncognito = (request) ->
chrome.windows.create({ url: Utils.convertToUrl(request.url), incognito: true})
#
-# Called when the user has clicked the close icon on the "Vimium has been updated" message.
-# We should now dismiss that message in all tabs.
-#
-upgradeNotificationClosed = (request) ->
- Settings.set("previousVersion", currentVersion)
- sendRequestToAllTabs({ name: "hideUpgradeNotification" })
-
-#
# Copies or pastes some data (request.data) to/from the clipboard.
# We return null to avoid the return value from the copy operations being passed to sendResponse.
#
@@ -182,21 +220,16 @@ selectSpecificTab = (request) ->
#
# Used by the content scripts to get settings from the local storage.
#
-handleSettings = (args, port) ->
- if (args.operation == "get")
- value = Settings.get(args.key)
- port.postMessage({ key: args.key, value: value })
- else # operation == "set"
- Settings.set(args.key, args.value)
-
-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 }))
-
-getCurrentTimeInSeconds = -> Math.floor((new Date()).getTime() / 1000)
+handleSettings = (request, port) ->
+ switch request.operation
+ when "get" # Get a single settings value.
+ port.postMessage key: request.key, value: Settings.get request.key
+ when "set" # Set a single settings value.
+ Settings.set request.key, request.value
+ when "fetch" # Fetch multiple settings values.
+ values = request.values
+ values[key] = Settings.get key for own key of values
+ port.postMessage { values }
chrome.tabs.onSelectionChanged.addListener (tabId, selectionInfo) ->
if (selectionChangedHandlers.length > 0)
@@ -219,7 +252,14 @@ moveTab = (callback, direction) ->
# These are commands which are bound to keystroke which must be handled by the background page. They are
# mapped in commands.coffee.
BackgroundCommands =
- createTab: (callback) -> chrome.tabs.create({url: Settings.get("newTabUrl")}, (tab) -> callback())
+ createTab: (callback) ->
+ chrome.tabs.query { active: true, currentWindow: true }, (tabs) ->
+ tab = tabs[0]
+ url = Settings.get "newTabUrl"
+ if url == "pages/blank.html"
+ # "pages/blank.html" does not work in incognito mode, so fall back to "chrome://newtab" instead.
+ url = if tab.incognito then "chrome://newtab" else chrome.runtime.getURL url
+ openUrlInNewTab { url }, callback
duplicateTab: (callback) ->
chrome.tabs.getSelected(null, (tab) ->
chrome.tabs.duplicate(tab.id)
@@ -228,10 +268,10 @@ BackgroundCommands =
chrome.tabs.query {active: true, currentWindow: true}, (tabs) ->
tab = tabs[0]
chrome.windows.create {tabId: tab.id, incognito: tab.incognito}
- nextTab: (callback) -> selectTab(callback, "next")
- previousTab: (callback) -> selectTab(callback, "previous")
- firstTab: (callback) -> selectTab(callback, "first")
- lastTab: (callback) -> selectTab(callback, "last")
+ nextTab: (count) -> selectTab "next", count
+ previousTab: (count) -> selectTab "previous", count
+ firstTab: -> selectTab "first"
+ lastTab: -> selectTab "last"
removeTab: (callback) ->
chrome.tabs.getSelected(null, (tab) ->
chrome.tabs.remove(tab.id)
@@ -271,13 +311,13 @@ BackgroundCommands =
moveTabLeft: (count) -> moveTab(null, -count)
moveTabRight: (count) -> moveTab(null, count)
nextFrame: (count,frameId) ->
- chrome.tabs.getSelected(null, (tab) ->
- frames = frameIdsForTab[tab.id]
- # We can't always track which frame chrome has focussed, but here we learn that it's frameId; so add an
- # additional offset such that we do indeed start from frameId.
- count = (count + Math.max 0, frameIdsForTab[tab.id].indexOf frameId) % frames.length
- frames = frameIdsForTab[tab.id] = [frames[count..]..., frames[0...count]...]
- chrome.tabs.sendMessage(tab.id, { name: "focusFrame", frameId: frames[0], highlight: true }))
+ chrome.tabs.getSelected null, (tab) ->
+ frameIdsForTab[tab.id] = cycleToFrame frameIdsForTab[tab.id], frameId, count
+ chrome.tabs.sendMessage tab.id, name: "focusFrame", frameId: frameIdsForTab[tab.id][0], highlight: true
+ mainFrame: ->
+ chrome.tabs.getSelected null, (tab) ->
+ # The front end interprets a frameId of 0 to mean the main/top from.
+ chrome.tabs.sendMessage tab.id, name: "focusFrame", frameId: 0, highlight: true
closeTabsOnLeft: -> removeTabsRelative "before"
closeTabsOnRight: -> removeTabsRelative "after"
@@ -305,21 +345,23 @@ removeTabsRelative = (direction) ->
# Selects a tab before or after the currently selected tab.
# - direction: "next", "previous", "first" or "last".
-selectTab = (callback, direction) ->
- chrome.tabs.getAllInWindow(null, (tabs) ->
+selectTab = (direction, count = 1) ->
+ chrome.tabs.getAllInWindow null, (tabs) ->
return unless tabs.length > 1
- chrome.tabs.getSelected(null, (currentTab) ->
- switch direction
- when "next"
- toSelect = tabs[(currentTab.index + 1 + tabs.length) % tabs.length]
- when "previous"
- toSelect = tabs[(currentTab.index - 1 + tabs.length) % tabs.length]
- when "first"
- toSelect = tabs[0]
- when "last"
- toSelect = tabs[tabs.length - 1]
- selectionChangedHandlers.push(callback)
- chrome.tabs.update(toSelect.id, { selected: true })))
+ chrome.tabs.getSelected null, (currentTab) ->
+ toSelect =
+ switch direction
+ when "next"
+ currentTab.index + count
+ when "previous"
+ currentTab.index - count
+ when "first"
+ 0
+ when "last"
+ tabs.length - 1
+ # Bring toSelect into the range [0,tabs.length).
+ toSelect = (toSelect + tabs.length * Math.abs count) % tabs.length
+ chrome.tabs.update tabs[toSelect].id, selected: true
updateOpenTabs = (tab, deleteFrames = false) ->
# Chrome might reuse the tab ID of a recently removed tab.
@@ -335,60 +377,27 @@ updateOpenTabs = (tab, deleteFrames = false) ->
# Frames are recreated on refresh
delete frameIdsForTab[tab.id] if deleteFrames
-setBrowserActionIcon = (tabId,path) ->
- chrome.browserAction.setIcon({ tabId: tabId, path: path })
-
-chrome.browserAction.setBadgeBackgroundColor
- # This is Vimium blue (from the icon).
- # color: [102, 176, 226, 255]
- # This is a slightly darker blue. It makes the badge more striking in the corner of the eye, and the symbol
- # easier to read.
- color: [82, 156, 206, 255]
-
-setBadge = do ->
- current = null
- timer = null
- updateBadge = (badge) -> -> chrome.browserAction.setBadgeText text: badge
- (request) ->
- badge = request.badge
- if badge? and badge != current
- current = badge
- clearTimeout timer if timer
- # We wait a few moments. This avoids badge flicker when there are rapid changes.
- timer = setTimeout updateBadge(badge), 50
-
-# Updates the browserAction icon to indicate whether Vimium is enabled or disabled on the current page.
-# Also propagates new enabled/disabled/passkeys state to active window, if necessary.
-# This lets you disable Vimium on a page without needing to reload.
-# Exported via root because it's called from the page popup.
-root.updateActiveState = updateActiveState = (tabId) ->
- enabledIcon = "icons/browser_action_enabled.png"
- disabledIcon = "icons/browser_action_disabled.png"
- partialIcon = "icons/browser_action_partial.png"
- chrome.tabs.get tabId, (tab) ->
- # Default to disabled state in case we can't connect to Vimium, primarily for the "New Tab" page.
- setBrowserActionIcon(tabId,disabledIcon)
- setBadge badge: ""
- chrome.tabs.sendMessage tabId, { name: "getActiveState" }, (response) ->
- if response
- isCurrentlyEnabled = response.enabled
- currentPasskeys = response.passKeys
- config = isEnabledForUrl({url: tab.url})
- enabled = config.isEnabledForUrl
- passKeys = config.passKeys
- if (enabled and passKeys)
- setBrowserActionIcon(tabId,partialIcon)
- else if (enabled)
- setBrowserActionIcon(tabId,enabledIcon)
- else
- setBrowserActionIcon(tabId,disabledIcon)
- # Propagate the new state only if it has changed.
- if (isCurrentlyEnabled != enabled || currentPasskeys != passKeys)
- chrome.tabs.sendMessage(tabId, { name: "setState", enabled: enabled, passKeys: passKeys, incognito: tab.incognito })
-
+# Here's how we set the page icon. The default is "disabled", so if we do nothing else, then we get the
+# grey-out disabled icon. Thereafter, we only set tab-specific icons, so there's no need to update the icon
+# when we visit a tab on which Vimium isn't running.
+#
+# For active tabs, when a frame starts, it requests its active state via isEnabledForUrl. We also check the
+# state every time a frame gets the focus. In both cases, the frame then updates the tab's icon accordingly.
+#
+# Exclusion rule changes (from either the options page or the page popup) propagate via the subsequent focus
+# change. In particular, whenever a frame next gets the focus, it requests its new state and sets the icon
+# accordingly.
+#
+setIcon = (request, sender) ->
+ path = switch request.icon
+ when "enabled" then "icons/browser_action_enabled.png"
+ when "partial" then "icons/browser_action_partial.png"
+ when "disabled" then "icons/browser_action_disabled.png"
+ chrome.browserAction.setIcon tabId: sender.tab.id, path: path
handleUpdateScrollPosition = (request, sender) ->
- updateScrollPosition(sender.tab, request.scrollX, request.scrollY)
+ # See note regarding sender.tab at unregisterFrame.
+ updateScrollPosition sender.tab, request.scrollX, request.scrollY if sender.tab?
updateScrollPosition = (tab, scrollX, scrollY) ->
tabInfoMap[tab.id].scrollX = scrollX
@@ -402,7 +411,6 @@ chrome.tabs.onUpdated.addListener (tabId, changeInfo, tab) ->
runAt: "document_start"
chrome.tabs.insertCSS tabId, cssConf, -> chrome.runtime.lastError
updateOpenTabs(tab) if changeInfo.url?
- updateActiveState(tabId)
chrome.tabs.onAttached.addListener (tabId, attachedInfo) ->
# We should update all the tabs in the old window and the new window.
@@ -437,8 +445,7 @@ chrome.tabs.onRemoved.addListener (tabId) ->
tabInfoMap.deletor = -> delete tabInfoMap[tabId]
setTimeout tabInfoMap.deletor, 1000
delete frameIdsForTab[tabId]
-
-chrome.tabs.onActiveChanged.addListener (tabId, selectInfo) -> updateActiveState(tabId)
+ delete urlForTab[tabId]
unless chrome.sessions
chrome.windows.onRemoved.addListener (windowId) -> delete tabQueue[windowId]
@@ -551,13 +558,13 @@ checkKeyQueue = (keysToCheck, tabId, frameId) ->
if runCommand
if not registryEntry.isBackgroundCommand
- chrome.tabs.sendMessage(tabId,
- name: "executePageCommand",
- command: registryEntry.command,
- frameId: frameId,
- count: count,
- passCountToFunction: registryEntry.passCountToFunction,
- completionKeys: generateCompletionKeys(""))
+ chrome.tabs.sendMessage tabId,
+ name: "executePageCommand"
+ command: registryEntry.command
+ frameId: frameId
+ count: count
+ completionKeys: generateCompletionKeys ""
+ registryEntry: registryEntry
refreshedCompletionKeys = true
else
if registryEntry.passCountToFunction
@@ -595,16 +602,6 @@ sendRequestToAllTabs = (args) ->
for tab in window.tabs
chrome.tabs.sendMessage(tab.id, args, null))
-#
-# Returns true if the current extension version is greater than the previously recorded version in
-# localStorage, and false otherwise.
-#
-shouldShowUpgradeMessage = ->
- # Avoid showing the upgrade notification when previousVersion is undefined, which is the case for new
- # installs.
- Settings.set("previousVersion", currentVersion) unless Settings.get("previousVersion")
- Utils.compareVersions(currentVersion, Settings.get("previousVersion")) == 1
-
openOptionsPageInNewTab = ->
chrome.tabs.getSelected(null, (tab) ->
chrome.tabs.create({ url: chrome.runtime.getURL("pages/options.html"), index: tab.index + 1 }))
@@ -613,7 +610,10 @@ registerFrame = (request, sender) ->
(frameIdsForTab[sender.tab.id] ?= []).push request.frameId
unregisterFrame = (request, sender) ->
- tabId = sender.tab.id
+ # When a tab is closing, Chrome sometimes passes messages without sender.tab. Therefore, we guard against
+ # this.
+ tabId = sender.tab?.id
+ return unless tabId?
if frameIdsForTab[tabId]?
if request.tab_is_closing
updateOpenTabs sender.tab, true
@@ -622,15 +622,36 @@ unregisterFrame = (request, sender) ->
handleFrameFocused = (request, sender) ->
tabId = sender.tab.id
+ # Cycle frameIdsForTab to the focused frame. However, also ensure that we don't inadvertently register a
+ # frame which wasn't previously registered (such as a frameset).
if frameIdsForTab[tabId]?
- frameIdsForTab[tabId] =
- [request.frameId, (frameIdsForTab[tabId].filter (id) -> id != request.frameId)...]
+ frameIdsForTab[tabId] = cycleToFrame frameIdsForTab[tabId], request.frameId
+ # Inform all frames that a frame has received the focus.
+ chrome.tabs.sendMessage sender.tab.id,
+ name: "frameFocused"
+ focusFrameId: request.frameId
+
+# Rotate through frames to the frame count places after frameId.
+cycleToFrame = (frames, frameId, count = 0) ->
+ frames ||= []
+ # We can't always track which frame chrome has focussed, but here we learn that it's frameId; so add an
+ # additional offset such that we do indeed start from frameId.
+ count = (count + Math.max 0, frames.indexOf frameId) % frames.length
+ [frames[count..]..., frames[0...count]...]
+
+# Send a message to all frames in the current tab.
+sendMessageToFrames = (request, sender) ->
+ chrome.tabs.sendMessage sender.tab.id, request.message
+
+# For debugging only. This allows content scripts to log messages to the background page's console.
+bgLog = (request, sender) ->
+ console.log "#{sender.tab.id}/#{request.frameId}", request.message
# Port handler mapping
portHandlers =
keyDown: handleKeyDown,
settings: handleSettings,
- filterCompleter: filterCompleter
+ completions: handleCompletions
sendRequestHandlers =
getCompletionKeys: getCompletionKeysRequest
@@ -643,16 +664,16 @@ sendRequestHandlers =
unregisterFrame: unregisterFrame
frameFocused: handleFrameFocused
nextFrame: (request) -> BackgroundCommands.nextFrame 1, request.frameId
- upgradeNotificationClosed: upgradeNotificationClosed
updateScrollPosition: handleUpdateScrollPosition
copyToClipboard: copyToClipboard
pasteFromClipboard: pasteFromClipboard
isEnabledForUrl: isEnabledForUrl
selectSpecificTab: selectSpecificTab
- refreshCompleter: refreshCompleter
createMark: Marks.create.bind(Marks)
gotoMark: Marks.goto.bind(Marks)
- setBadge: setBadge
+ setIcon: setIcon
+ sendMessageToFrames: sendMessageToFrames
+ log: bgLog
# We always remove chrome.storage.local/findModeRawQueryListIncognito on startup.
chrome.storage.local.remove "findModeRawQueryListIncognito"
@@ -668,6 +689,13 @@ chrome.tabs.onRemoved.addListener (tabId) ->
# There are no remaining incognito-mode tabs, and findModeRawQueryListIncognito is set.
chrome.storage.local.remove "findModeRawQueryListIncognito"
+# Tidy up tab caches when tabs are removed. We cannot rely on unregisterFrame because Chrome does not always
+# provide sender.tab there.
+# NOTE(smblott) (2015-05-05) This may break restoreTab on legacy Chrome versions, but we'll be moving to
+# chrome.sessions support only soon anyway.
+chrome.tabs.onRemoved.addListener (tabId) ->
+ delete cache[tabId] for cache in [ frameIdsForTab, urlForTab, tabInfoMap ]
+
# Convenience function for development use.
window.runTests = -> open(chrome.runtime.getURL('tests/dom_tests/dom_tests.html'))
@@ -681,8 +709,30 @@ if Settings.has("keyMappings")
populateValidFirstKeys()
populateSingleKeyCommands()
-if shouldShowUpgradeMessage()
- sendRequestToAllTabs({ name: "showUpgradeNotification", version: currentVersion })
+
+# Show notification on upgrade.
+showUpgradeMessage = ->
+ # Avoid showing the upgrade notification when previousVersion is undefined, which is the case for new
+ # installs.
+ Settings.set "previousVersion", currentVersion unless Settings.get "previousVersion"
+ if Utils.compareVersions(currentVersion, Settings.get "previousVersion" ) == 1
+ notificationId = "VimiumUpgradeNotification"
+ notification =
+ type: "basic"
+ iconUrl: chrome.runtime.getURL "icons/vimium.png"
+ title: "Vimium Upgrade"
+ message: "Vimium has been upgraded to version #{currentVersion}. Click here for more information."
+ isClickable: true
+ if chrome.notifications?.create?
+ chrome.notifications.create notificationId, notification, ->
+ unless chrome.runtime.lastError
+ Settings.set "previousVersion", currentVersion
+ chrome.notifications.onClicked.addListener (id) ->
+ if id == notificationId
+ openUrlInNewTab url: "https://github.com/philc/vimium#release-notes"
+ else
+ # We need to wait for the user to accept the "notifications" permission.
+ chrome.permissions.onAdded.addListener showUpgradeMessage
# Ensure that tabInfoMap is populated when Vimium is installed.
chrome.windows.getAll { populate: true }, (windows) ->
@@ -694,4 +744,5 @@ chrome.windows.getAll { populate: true }, (windows) ->
chrome.tabs.sendMessage(tab.id, { name: "getScrollPosition" }, createScrollPositionHandler())
# Start pulling changes from synchronized storage.
-Sync.init()
+Settings.init()
+showUpgradeMessage()
diff --git a/background_scripts/settings.coffee b/background_scripts/settings.coffee
deleted file mode 100644
index 3528e8a9..00000000
--- a/background_scripts/settings.coffee
+++ /dev/null
@@ -1,133 +0,0 @@
-#
-# Used by all parts of Vimium to manipulate localStorage.
-#
-
-root = exports ? window
-root.Settings = Settings =
- get: (key) ->
- if (key of localStorage) then JSON.parse(localStorage[key]) else @defaults[key]
-
- set: (key, value) ->
- # Don't store the value if it is equal to the default, so we can change the defaults in the future
- if (value == @defaults[key])
- @clear(key)
- else
- jsonValue = JSON.stringify value
- localStorage[key] = jsonValue
- Sync.set key, jsonValue
-
- clear: (key) ->
- if @has key
- delete localStorage[key]
- Sync.clear key
-
- has: (key) -> key of localStorage
-
- # For settings which require action when their value changes, add hooks here called from
- # options/options.coffee (when the options page is saved), and from background_scripts/sync.coffee (when an
- # update propagates from chrome.storage.sync).
- postUpdateHooks:
- keyMappings: (value) ->
- root.Commands.clearKeyMappingsAndSetDefaults()
- root.Commands.parseCustomKeyMappings value
- root.refreshCompletionKeysAfterMappingSave()
-
- searchEngines: (value) ->
- root.Settings.parseSearchEngines value
-
- exclusionRules: (value) ->
- root.Exclusions.postUpdateHook value
-
- # postUpdateHooks convenience wrapper
- performPostUpdateHook: (key, value) ->
- @postUpdateHooks[key] value if @postUpdateHooks[key]
-
- # Here we have our functions that parse the search engines
- # this is a map that we use to store our search engines for use.
- searchEnginesMap: {}
-
- # Parse the custom search engines setting and cache it.
- parseSearchEngines: (searchEnginesText) ->
- @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: ->
- this.parseSearchEngines(@get("searchEngines") || "") if Object.keys(@searchEnginesMap).length == 0
- @searchEnginesMap
-
- # options.coffee and options.html only handle booleans and strings; therefore all defaults must be booleans
- # or strings
- defaults:
- scrollStepSize: 60
- smoothScroll: true
- keyMappings: "# Insert your preferred key mappings here."
- linkHintCharacters: "sadfjklewcmpgh"
- linkHintNumbers: "0123456789"
- filterLinkHints: false
- hideHud: false
- userDefinedLinkHintCss:
- """
- div > .vimiumHintMarker {
- /* linkhint boxes */
- background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#FFF785),
- color-stop(100%,#FFC542));
- border: 1px solid #E3BE23;
- }
-
- div > .vimiumHintMarker span {
- /* linkhint text */
- color: black;
- font-weight: bold;
- font-size: 12px;
- }
-
- div > .vimiumHintMarker > .matchingCharacter {
- }
- """
- # Default exclusion rules.
- exclusionRules:
- [
- # Disable Vimium on Gmail.
- { pattern: "http*://mail.google.com/*", passKeys: "" }
- ]
-
- # NOTE: If a page contains both a single angle-bracket link and a double angle-bracket link, then in
- # most cases the single bracket link will be "prev/next page" and the double bracket link will be
- # "first/last page", so we put the single bracket first in the pattern string so that it gets searched
- # for first.
-
- # "\bprev\b,\bprevious\b,\bback\b,<,←,«,≪,<<"
- previousPatterns: "prev,previous,back,<,\u2190,\xab,\u226a,<<"
- # "\bnext\b,\bmore\b,>,→,»,≫,>>"
- nextPatterns: "next,more,>,\u2192,\xbb,\u226b,>>"
- # default/fall back search engine
- searchUrl: "http://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"
- newTabUrl: "chrome://newtab"
- grabBackFocus: false
-
- settingsVersion: Utils.getCurrentVersion()
-
-
-# We use settingsVersion to coordinate any necessary schema changes.
-if Utils.compareVersions("1.42", Settings.get("settingsVersion")) != -1
- Settings.set("scrollStepSize", parseFloat Settings.get("scrollStepSize"))
-Settings.set("settingsVersion", Utils.getCurrentVersion())
-
-# Migration (after 1.49, 2015/2/1).
-# Legacy setting: findModeRawQuery (a string).
-# New setting: findModeRawQueryList (a list of strings), now stored in chrome.storage.local (not localStorage).
-chrome.storage.local.get "findModeRawQueryList", (items) ->
- unless chrome.runtime.lastError or items.findModeRawQueryList
- rawQuery = Settings.get "findModeRawQuery"
- chrome.storage.local.set findModeRawQueryList: (if rawQuery then [ rawQuery ] else [])
-
diff --git a/background_scripts/sync.coffee b/background_scripts/sync.coffee
deleted file mode 100644
index d0d501d3..00000000
--- a/background_scripts/sync.coffee
+++ /dev/null
@@ -1,74 +0,0 @@
-#
-# * Sync.set() and Sync.clear() propagate local changes to chrome.storage.sync.
-# * Sync.handleStorageUpdate() listens for changes to chrome.storage.sync and propagates those
-# changes to localStorage and into vimium's internal state.
-# * Sync.fetchAsync() polls chrome.storage.sync at startup, similarly propagating
-# changes to localStorage and into vimium's internal state.
-#
-# Changes are propagated into vimium's state using the same mechanism
-# (Settings.performPostUpdateHook) that is used when options are changed on
-# the options page.
-#
-# The effect is best-effort synchronization of vimium options/settings between
-# chrome/vimium instances.
-#
-# NOTE:
-# Values handled within this module are ALWAYS already JSON.stringifed, so
-# they're always non-empty strings.
-#
-
-root = exports ? window
-root.Sync = Sync =
-
- storage: chrome.storage.sync
- doNotSync: ["settingsVersion", "previousVersion"]
-
- # This is called in main.coffee.
- init: ->
- chrome.storage.onChanged.addListener (changes, area) -> Sync.handleStorageUpdate changes, area
- @fetchAsync()
-
- # Asynchronous fetch from synced storage, called only at startup.
- fetchAsync: ->
- @storage.get null, (items) =>
- unless chrome.runtime.lastError
- for own key, value of items
- @storeAndPropagate key, value
-
- # Asynchronous message from synced storage.
- handleStorageUpdate: (changes, area) ->
- for own key, change of changes
- @storeAndPropagate key, change?.newValue
-
- # Only ever called from asynchronous synced-storage callbacks (fetchAsync and handleStorageUpdate).
- storeAndPropagate: (key, value) ->
- return unless key of Settings.defaults
- return if not @shouldSyncKey key
- return if value and key of localStorage and localStorage[key] is value
- defaultValue = Settings.defaults[key]
- defaultValueJSON = JSON.stringify(defaultValue)
-
- if value and value != defaultValueJSON
- # Key/value has been changed to non-default value at remote instance.
- localStorage[key] = value
- Settings.performPostUpdateHook key, JSON.parse(value)
- else
- # Key has been reset to default value at remote instance.
- if key of localStorage
- delete localStorage[key]
- Settings.performPostUpdateHook key, defaultValue
-
- # Only called synchronously from within vimium, never on a callback.
- # No need to propagate updates to the rest of vimium, that's already been done.
- set: (key, value) ->
- if @shouldSyncKey key
- setting = {}; setting[key] = value
- @storage.set setting
-
- # Only called synchronously from within vimium, never on a callback.
- clear: (key) ->
- @storage.remove key if @shouldSyncKey key
-
- # Should we synchronize this key?
- shouldSyncKey: (key) -> key not in @doNotSync
-