diff options
Diffstat (limited to 'background_scripts')
| -rw-r--r-- | background_scripts/commands.coffee | 97 | ||||
| -rw-r--r-- | background_scripts/completion.coffee | 477 | ||||
| -rw-r--r-- | background_scripts/completion_engines.coffee | 135 | ||||
| -rw-r--r-- | background_scripts/completion_search.coffee | 139 | ||||
| -rw-r--r-- | background_scripts/exclusions.coffee | 13 | ||||
| -rw-r--r-- | background_scripts/main.coffee | 363 | ||||
| -rw-r--r-- | background_scripts/settings.coffee | 133 | ||||
| -rw-r--r-- | background_scripts/sync.coffee | 74 |
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 = "↪" # 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 - |
