diff options
Diffstat (limited to 'background_scripts')
| -rw-r--r-- | background_scripts/commands.coffee | 185 | ||||
| -rw-r--r-- | background_scripts/completion.coffee | 146 | ||||
| -rw-r--r-- | background_scripts/exclusions.coffee | 70 | ||||
| -rw-r--r-- | background_scripts/main.coffee | 266 | ||||
| -rw-r--r-- | background_scripts/settings.coffee | 68 | ||||
| -rw-r--r-- | background_scripts/sync.coffee | 102 | 
6 files changed, 661 insertions, 176 deletions
| diff --git a/background_scripts/commands.coffee b/background_scripts/commands.coffee index ce159c71..0ade35ec 100644 --- a/background_scripts/commands.coffee +++ b/background_scripts/commands.coffee @@ -22,17 +22,21 @@ Commands =        isBackgroundCommand: options.background        passCountToFunction: options.passCountToFunction        noRepeat: options.noRepeat +      repeatLimit: options.repeatLimit    mapKeyToCommand: (key, command) ->      unless @availableCommands[command]        console.log(command, "doesn't exist!")        return +    commandDetails = @availableCommands[command] +      @keyToCommandRegistry[key] =        command: command -      isBackgroundCommand: @availableCommands[command].isBackgroundCommand -      passCountToFunction: @availableCommands[command].passCountToFunction -      noRepeat: @availableCommands[command].noRepeat +      isBackgroundCommand: commandDetails.isBackgroundCommand +      passCountToFunction: commandDetails.passCountToFunction +      noRepeat: commandDetails.noRepeat +      repeatLimit: commandDetails.repeatLimit    unmapKey: (key) -> delete @keyToCommandRegistry[key] @@ -86,21 +90,63 @@ Commands =    # be shown in the help page.    commandGroups:      pageNavigation: -      ["scrollDown", "scrollUp", "scrollLeft", "scrollRight", -       "scrollToTop", "scrollToBottom", "scrollToLeft", "scrollToRight", "scrollPageDown", -       "scrollPageUp", "scrollFullPageUp", "scrollFullPageDown", -       "reload", "toggleViewSource", "copyCurrentUrl", "LinkHints.activateModeToCopyLinkUrl", -       "openCopiedUrlInCurrentTab", "openCopiedUrlInNewTab", "goUp", "goToRoot", -       "enterInsertMode", "focusInput", -       "LinkHints.activateMode", "LinkHints.activateModeToOpenInNewTab", "LinkHints.activateModeWithQueue", -       "Vomnibar.activate", "Vomnibar.activateInNewTab", "Vomnibar.activateTabSelection", -       "Vomnibar.activateBookmarks", "Vomnibar.activateBookmarksInNewTab", -       "goPrevious", "goNext", "nextFrame", "Marks.activateCreateMode", "Marks.activateGotoMode"] +      ["scrollDown", +      "scrollUp", +      "scrollLeft", +      "scrollRight", +      "scrollToTop", +      "scrollToBottom", +      "scrollToLeft", +      "scrollToRight", +      "scrollPageDown", +      "scrollPageUp", +      "scrollFullPageUp", +      "scrollFullPageDown", +      "reload", +      "toggleViewSource", +      "copyCurrentUrl", +      "LinkHints.activateModeToCopyLinkUrl", +      "openCopiedUrlInCurrentTab", +      "openCopiedUrlInNewTab", +      "goUp", +      "goToRoot", +      "enterInsertMode", +      "focusInput", +      "LinkHints.activateMode", +      "LinkHints.activateModeToOpenInNewTab", +      "LinkHints.activateModeToOpenInNewForegroundTab", +      "LinkHints.activateModeWithQueue", +      "LinkHints.activateModeToDownloadLink", +      "Vomnibar.activate", +      "Vomnibar.activateInNewTab", +      "Vomnibar.activateTabSelection", +      "Vomnibar.activateBookmarks", +      "Vomnibar.activateBookmarksInNewTab", +      "goPrevious", +      "goNext", +      "nextFrame", +      "Marks.activateCreateMode", +      "Vomnibar.activateEditUrl", +      "Vomnibar.activateEditUrlInNewTab", +      "Marks.activateGotoMode"]      findCommands: ["enterFindMode", "performFind", "performBackwardsFind"]      historyNavigation:        ["goBack", "goForward"]      tabManipulation: -      ["nextTab", "previousTab", "firstTab", "lastTab", "createTab", "duplicateTab", "removeTab", "restoreTab", "moveTabToNewWindow"] +      ["nextTab", +      "previousTab", +      "firstTab", +      "lastTab", +      "createTab", +      "duplicateTab", +      "removeTab", +      "restoreTab", +      "moveTabToNewWindow", +      "togglePinTab", +      "closeTabsOnLeft","closeTabsOnRight", +      "closeOtherTabs", +      "moveTabLeft", +      "moveTabRight"]      misc:        ["showHelp"] @@ -108,10 +154,26 @@ Commands =    # a focused, high-signal set of commands to the new and casual user. Only those truly hungry for more power    # from Vimium will uncover these gems.    advancedCommands: [ -    "scrollToLeft", "scrollToRight", "moveTabToNewWindow", -    "goUp", "goToRoot", "focusInput", "LinkHints.activateModeWithQueue", -    "LinkHints.activateModeToOpenIncognito", "goNext", "goPrevious", "Marks.activateCreateMode", -    "Marks.activateGotoMode"] +    "scrollToLeft", +    "scrollToRight", +    "moveTabToNewWindow", +    "goUp", +    "goToRoot", +    "focusInput", +    "LinkHints.activateModeWithQueue", +    "LinkHints.activateModeToDownloadLink", +    "Vomnibar.activateEditUrl", +    "Vomnibar.activateEditUrlInNewTab", +    "LinkHints.activateModeToOpenIncognito", +    "goNext", +    "goPrevious", +    "Marks.activateCreateMode", +    "Marks.activateGotoMode", +    "moveTabLeft", +    "moveTabRight", +    "closeTabsOnLeft", +    "closeTabsOnRight", +    "closeOtherTabs"]  defaultKeyMappings =    "?": "showHelp" @@ -144,6 +206,8 @@ defaultKeyMappings =    "F":     "LinkHints.activateModeToOpenInNewTab"    "<a-f>": "LinkHints.activateModeWithQueue" +  "af": "LinkHints.activateModeToDownloadLink" +    "/": "enterFindMode"    "n": "performFind"    "N": "performBackwardsFind" @@ -161,6 +225,8 @@ defaultKeyMappings =    "J": "previousTab"    "gt": "nextTab"    "gT": "previousTab" +  "<<": "moveTabLeft" +  ">>": "moveTabRight"    "g0": "firstTab"    "g$": "lastTab" @@ -170,6 +236,8 @@ defaultKeyMappings =    "x": "removeTab"    "X": "restoreTab" +  "<a-p>": "togglePinTab" +    "o": "Vomnibar.activate"    "O": "Vomnibar.activateInNewTab" @@ -178,6 +246,9 @@ defaultKeyMappings =    "b": "Vomnibar.activateBookmarks"    "B": "Vomnibar.activateBookmarksInNewTab" +  "ge": "Vomnibar.activateEditUrl" +  "gE": "Vomnibar.activateEditUrlInNewTab" +    "gf": "nextFrame"    "m": "Marks.activateCreateMode" @@ -185,6 +256,7 @@ defaultKeyMappings =  # This is a mapping of: commandIdentifier => [description, options]. +# If the noRepeat and repeatLimit options are both specified, then noRepeat takes precedence.  commandDescriptions =    # Navigating the current page    showHelp: ["Show help", { background: true }] @@ -192,40 +264,43 @@ commandDescriptions =    scrollUp: ["Scroll up"]    scrollLeft: ["Scroll left"]    scrollRight: ["Scroll right"] -  scrollToTop: ["Scroll to the top of the page"] -  scrollToBottom: ["Scroll to the bottom of the page"] -  scrollToLeft: ["Scroll all the way to the left"] -  scrollToRight: ["Scroll all the way to the right"] +  scrollToTop: ["Scroll to the top of the page", { noRepeat: true }] +  scrollToBottom: ["Scroll to the bottom of the page", { noRepeat: true }] +  scrollToLeft: ["Scroll all the way to the left", { noRepeat: true }] +  scrollToRight: ["Scroll all the way to the right", { noRepeat: true }] +    scrollPageDown: ["Scroll a page down"]    scrollPageUp: ["Scroll a page up"]    scrollFullPageDown: ["Scroll a full page down"]    scrollFullPageUp: ["Scroll a full page up"] -  reload: ["Reload the page"] -  toggleViewSource: ["View page source"] +  reload: ["Reload the page", { noRepeat: true }] +  toggleViewSource: ["View page source", { noRepeat: true }] -  copyCurrentUrl: ["Copy the current URL to the clipboard"] -  'LinkHints.activateModeToCopyLinkUrl': ["Copy a link URL to the clipboard"] +  copyCurrentUrl: ["Copy the current URL to the clipboard", { noRepeat: true }] +  "LinkHints.activateModeToCopyLinkUrl": ["Copy a link URL to the clipboard", { noRepeat: true }]    openCopiedUrlInCurrentTab: ["Open the clipboard's URL in the current tab", { background: true }] -  openCopiedUrlInNewTab: ["Open the clipboard's URL in a new tab", { background: true }] +  openCopiedUrlInNewTab: ["Open the clipboard's URL in a new tab", { background: true, repeatLimit: 20 }] -  enterInsertMode: ["Enter insert mode"] +  enterInsertMode: ["Enter insert mode", { noRepeat: true }] -  focusInput: ["Focus the first (or n-th) text box on the page", { passCountToFunction: true }] +  focusInput: ["Focus the first text box on the page. Cycle between them using tab", +    { passCountToFunction: true }] -  'LinkHints.activateMode': ["Open a link in the current tab"] -  'LinkHints.activateModeToOpenInNewTab': ["Open a link in a new tab"] -  'LinkHints.activateModeWithQueue': ["Open multiple links in a new tab"] +  "LinkHints.activateMode": ["Open a link in the current tab", { noRepeat: true }] +  "LinkHints.activateModeToOpenInNewTab": ["Open a link in a new tab", { noRepeat: true }] +  "LinkHints.activateModeToOpenInNewForegroundTab": ["Open a link in a new tab & switch to it", { noRepeat: true }] +  "LinkHints.activateModeWithQueue": ["Open multiple links in a new tab", { noRepeat: true }] +  "LinkHints.activateModeToOpenIncognito": ["Open a link in incognito window", { noRepeat: true }] +  "LinkHints.activateModeToDownloadLink": ["Download link url", { noRepeat: true }] -  "LinkHints.activateModeToOpenIncognito": ["Open a link in incognito window"] - -  enterFindMode: ["Enter find mode"] +  enterFindMode: ["Enter find mode", { noRepeat: true }]    performFind: ["Cycle forward to the next find match"]    performBackwardsFind: ["Cycle backward to the previous find match"] -  goPrevious: ["Follow the link labeled previous or <"] -  goNext: ["Follow the link labeled next or >"] +  goPrevious: ["Follow the link labeled previous or <", { noRepeat: true }] +  goNext: ["Follow the link labeled next or >", { noRepeat: true }]    # Navigating your history    goBack: ["Go back in history", { passCountToFunction: true }] @@ -240,22 +315,36 @@ commandDescriptions =    previousTab: ["Go one tab left", { background: true }]    firstTab: ["Go to the first tab", { background: true }]    lastTab: ["Go to the last tab", { background: true }] -  createTab: ["Create new tab", { background: true }] -  duplicateTab: ["Duplicate current tab", { background: true }] -  removeTab: ["Close current tab", { background: true, noRepeat: true }] -  restoreTab: ["Restore closed tab", { background: true }] + +  createTab: ["Create new tab", { background: true, repeatLimit: 20 }] +  duplicateTab: ["Duplicate current tab", { background: true, repeatLimit: 20 }] +  removeTab: ["Close current tab", { background: true, repeatLimit: +    # Require confirmation to remove more tabs than we can restore. +    (if chrome.session then chrome.session.MAX_SESSION_RESULTS else 25) }] +  restoreTab: ["Restore closed tab", { background: true, repeatLimit: 20 }] +    moveTabToNewWindow: ["Move tab to new window", { background: true }] +  togglePinTab: ["Pin/unpin current tab", { background: true }] + +  closeTabsOnLeft: ["Close tabs on the left", {background: true, noRepeat: true}] +  closeTabsOnRight: ["Close tabs on the right", {background: true, noRepeat: true}] +  closeOtherTabs: ["Close all other tabs", {background: true, noRepeat: true}] + +  moveTabLeft: ["Move tab to the left", { background: true, passCountToFunction: true }] +  moveTabRight: ["Move tab to the right", { background: true, passCountToFunction: true  }] -  "Vomnibar.activate": ["Open URL, bookmark, or history entry"] -  "Vomnibar.activateInNewTab": ["Open URL, bookmark, history entry, in a new tab"] -  "Vomnibar.activateTabSelection": ["Search through your open tabs"] -  "Vomnibar.activateBookmarks": ["Open a bookmark"] -  "Vomnibar.activateBookmarksInNewTab": ["Open a bookmark in a new tab"] +  "Vomnibar.activate": ["Open URL, bookmark, or history entry", { noRepeat: true }] +  "Vomnibar.activateInNewTab": ["Open URL, bookmark, history entry, in a new tab", { noRepeat: true }] +  "Vomnibar.activateTabSelection": ["Search through your open tabs", { noRepeat: true }] +  "Vomnibar.activateBookmarks": ["Open a bookmark", { noRepeat: true }] +  "Vomnibar.activateBookmarksInNewTab": ["Open a bookmark in a new tab", { noRepeat: true }] +  "Vomnibar.activateEditUrl": ["Edit the current URL", { noRepeat: true }] +  "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 }] -  "Marks.activateCreateMode": ["Create a new mark"] -  "Marks.activateGotoMode": ["Go to a mark"] +  "Marks.activateCreateMode": ["Create a new mark", { noRepeat: true }] +  "Marks.activateGotoMode": ["Go to a mark", { noRepeat: true }]  Commands.init() diff --git a/background_scripts/completion.coffee b/background_scripts/completion.coffee index a03a3006..b52d9eb8 100644 --- a/background_scripts/completion.coffee +++ b/background_scripts/completion.coffee @@ -37,18 +37,20 @@ class Suggestion         </div>         <div class="vimiumReset vomnibarBottomHalf vomnibarIcon"              style="background-image: url(#{favIconUrl});"> -        <span class="vimiumReset vomnibarUrl">#{@shortenUrl(@highlightTerms(@url))}</span> +        <span class="vimiumReset vomnibarUrl">#{@shortenUrl(@highlightTerms(Utils.escapeHtml(@url)))}</span>          #{relevancyHtml}        </div>        """ -  # use neat trick to snatch a domain (http://stackoverflow.com/a/8498668) +  # Use neat trick to snatch a domain (http://stackoverflow.com/a/8498668). +  # TODO(smblott) Is this really faster than using parseUri?  That's probably what's happening behind the +  # scenes anyway.    getUrlRoot: (url) ->      a = document.createElement 'a'      a.href = url      a.protocol + "//" + a.hostname -  shortenUrl: (url) -> @stripTrailingSlash(url).replace(/^http:\/\//, "") +  shortenUrl: (url) -> @stripTrailingSlash(url).replace(/^https?:\/\//, "")    stripTrailingSlash: (url) ->      url = url.substring(url, url.length - 1) if url[url.length - 1] == "/" @@ -79,7 +81,8 @@ class Suggestion    # Wraps each occurence of the query terms in the given string in a <span>.    highlightTerms: (string) ->      ranges = [] -    for term in @queryTerms +    escapedTerms = @queryTerms.map (term) -> Utils.escapeHtml(term) +    for term in escapedTerms        @pushMatchingRanges string, term, ranges      return string if ranges.length == 0 @@ -109,6 +112,7 @@ class Suggestion  class BookmarkCompleter +  folderSeparator: "/"    currentSearch: null    # These bookmarks are loaded asynchronously when refresh() is called.    bookmarks: null @@ -120,14 +124,19 @@ class BookmarkCompleter    onBookmarksLoaded: -> @performSearch() if @currentSearch    performSearch: -> +    # If the folder separator character the first character in any query term, then we'll use the bookmark's full path as its title. +    # Otherwise, we'll just use the its regular title. +    usePathAndTitle = @currentSearch.queryTerms.reduce ((prev,term) => prev || term.indexOf(@folderSeparator) == 0), false      results =        if @currentSearch.queryTerms.length > 0          @bookmarks.filter (bookmark) => -          RankingUtils.matches(@currentSearch.queryTerms, bookmark.url, bookmark.title) +          suggestionTitle = if usePathAndTitle then bookmark.pathAndTitle else bookmark.title +          RankingUtils.matches(@currentSearch.queryTerms, bookmark.url, suggestionTitle)        else          []      suggestions = results.map (bookmark) => -      new Suggestion(@currentSearch.queryTerms, "bookmark", bookmark.url, bookmark.title, @computeRelevancy) +      suggestionTitle = if usePathAndTitle then bookmark.pathAndTitle else bookmark.title +      new Suggestion(@currentSearch.queryTerms, "bookmark", bookmark.url, suggestionTitle, @computeRelevancy)      onComplete = @currentSearch.onComplete      @currentSearch = null      onComplete(suggestions) @@ -138,16 +147,29 @@ class BookmarkCompleter        @bookmarks = @traverseBookmarks(bookmarks).filter((bookmark) -> bookmark.url?)        @onBookmarksLoaded() -  # Traverses the bookmark hierarchy, and retuns a flattened list of all bookmarks in the tree. +  # If these names occur as top-level bookmark names, then they are not included in the names of bookmark folders. +  ignoreTopLevel: +    'Other Bookmarks': true +    'Mobile Bookmarks': true +    'Bookmarks Bar': true + +  # Traverses the bookmark hierarchy, and returns a flattened list of all bookmarks.    traverseBookmarks: (bookmarks) ->      results = [] -    toVisit = bookmarks.reverse() -    while toVisit.length > 0 -      bookmark = toVisit.pop() -      results.push(bookmark) -      toVisit.push.apply(toVisit, bookmark.children.reverse()) if (bookmark.children) +    bookmarks.forEach (folder) => +      @traverseBookmarksRecursive folder, results      results +  # Recursive helper for `traverseBookmarks`. +  traverseBookmarksRecursive: (bookmark, results, parent={pathAndTitle:""}) -> +    bookmark.pathAndTitle = +      if bookmark.title and not (parent.pathAndTitle == "" and @ignoreTopLevel[bookmark.title]) +        parent.pathAndTitle + @folderSeparator + bookmark.title +      else +        parent.pathAndTitle +    results.push bookmark +    bookmark.children.forEach((child) => @traverseBookmarksRecursive child, results, bookmark) if bookmark.children +    computeRelevancy: (suggestion) ->      RankingUtils.wordRelevancy(suggestion.queryTerms, suggestion.url, suggestion.title) @@ -256,6 +278,27 @@ class TabCompleter    computeRelevancy: (suggestion) ->      RankingUtils.wordRelevancy(suggestion.queryTerms, suggestion.url, suggestion.title) +# A completer which will return your search engines +class SearchEngineCompleter +  searchEngines: {} + +  filter: (queryTerms, onComplete) -> +    searchEngineMatch = this.getSearchEngineMatches(queryTerms[0]) +    suggestions = [] +    if searchEngineMatch +      searchEngineMatch = searchEngineMatch.replace(/%s/g, queryTerms[1..].join(" ")) +      suggestion = new Suggestion(queryTerms, "search", searchEngineMatch, queryTerms[0] + ": " + queryTerms[1..].join(" "), @computeRelevancy) +      suggestions.push(suggestion) +    onComplete(suggestions) + +  computeRelevancy: -> 1 + +  refresh: -> +    this.searchEngines = root.Settings.getSearchEngines() + +  getSearchEngineMatches: (queryTerm) -> +    this.searchEngines[queryTerm] +  # A completer which calls filter() on many completers, aggregates the results, ranks them, and returns the top  # 10. Queries from the vomnibar frontend script come through a multi completer.  class MultiCompleter @@ -304,24 +347,79 @@ RankingUtils =        return false unless matchedTerm      true +  # Weights used for scoring matches. +  matchWeights: +    matchAnywhere:     1 +    matchStartOfWord:  1 +    matchWholeWord:    1 +    # The following must be the sum of the three weights above; it is used for normalization. +    maximumScore:      3 +    # +    # Calibration factor for balancing word relevancy and recency. +    recencyCalibrator: 2.0/3.0 +    # The current value of 2.0/3.0 has the effect of: +    #   - favoring the contribution of recency when matches are not on word boundaries ( because 2.0/3.0 > (1)/3     ) +    #   - favoring the contribution of word relevance when matches are on whole words  ( because 2.0/3.0 < (1+1+1)/3 ) + +  # Calculate a score for matching term against string. +  # The score is in the range [0, matchWeights.maximumScore], see above. +  # Returns: [ score, count ], where count is the number of matched characters in string. +  scoreTerm: (term, string) -> +    score = 0 +    count = 0 +    nonMatching = string.split(RegexpCache.get term) +    if nonMatching.length > 1 +      # Have match. +      score = RankingUtils.matchWeights.matchAnywhere +      count = nonMatching.reduce(((p,c) -> p - c.length), string.length) +      if RegexpCache.get(term, "\\b").test string +        # Have match at start of word. +        score += RankingUtils.matchWeights.matchStartOfWord +        if RegexpCache.get(term, "\\b", "\\b").test string +          # Have match of whole word. +          score += RankingUtils.matchWeights.matchWholeWord +    [ score, if count < string.length then count else string.length ] +    # Returns a number between [0, 1] indicating how often the query terms appear in the url and title.    wordRelevancy: (queryTerms, url, title) -> -    queryLength = 0 -    urlScore = 0.0 -    titleScore = 0.0 +    urlScore = titleScore = 0.0 +    urlCount = titleCount = 0 +    # Calculate initial scores.      for term in queryTerms -      queryLength += term.length -      urlScore += 1 if url && RankingUtils.matches [term], url -      titleScore += 1 if title && RankingUtils.matches [term], title -    urlScore = urlScore / queryTerms.length -    urlScore = urlScore * RankingUtils.normalizeDifference(queryLength, url.length) +      [ s, c ] = RankingUtils.scoreTerm term, url +      urlScore += s +      urlCount += c +      if title +        [ s, c ] = RankingUtils.scoreTerm term, title +        titleScore += s +        titleCount += c + +    maximumPossibleScore = RankingUtils.matchWeights.maximumScore * queryTerms.length + +    # Normalize scores. +    urlScore /= maximumPossibleScore +    urlScore *= RankingUtils.normalizeDifference urlCount, url.length +      if title -      titleScore = titleScore / queryTerms.length -      titleScore = titleScore * RankingUtils.normalizeDifference(queryLength, title.length) +      titleScore /= maximumPossibleScore +      titleScore *= RankingUtils.normalizeDifference titleCount, title.length      else        titleScore = urlScore + +    # Prefer matches in the title over matches in the URL. +    # In other words, don't let a poor urlScore pull down the titleScore. +    # For example, urlScore can be unreasonably poor if the URL is very long. +    urlScore = titleScore if urlScore < titleScore + +    # Return the average.      (urlScore + titleScore) / 2 +    # Untested alternative to the above: +    #   - Don't let a poor urlScore pull down a good titleScore, and don't let a poor titleScore pull down a +    #     good urlScore. +    # +    # return Math.max(urlScore, titleScore) +    # Returns a score between [0, 1] which indicates how recent the given timestamp is. Items which are over    # a month old are counted as 0. This range is quadratic, so an item from one day ago has a much stronger    # score than an item from two days ago. @@ -334,6 +432,9 @@ RankingUtils =      # incresingly discount older history entries.      recencyScore = recencyDifference * recencyDifference * recencyDifference +    # Calibrate recencyScore vis-a-vis word-relevancy scores. +    recencyScore *= RankingUtils.matchWeights.recencyCalibrator +    # Takes the difference of two numbers and returns a number between [0, 1] (the percentage difference).    normalizeDifference: (a, b) ->      max = Math.max(a, b) @@ -444,6 +545,7 @@ root.MultiCompleter = MultiCompleter  root.HistoryCompleter = HistoryCompleter  root.DomainCompleter = DomainCompleter  root.TabCompleter = TabCompleter +root.SearchEngineCompleter = SearchEngineCompleter  root.HistoryCache = HistoryCache  root.RankingUtils = RankingUtils  root.RegexpCache = RegexpCache diff --git a/background_scripts/exclusions.coffee b/background_scripts/exclusions.coffee new file mode 100644 index 00000000..3a8ef1e7 --- /dev/null +++ b/background_scripts/exclusions.coffee @@ -0,0 +1,70 @@ +root = exports ? window + +RegexpCache = +  cache: {} +  get: (pattern) -> +    if regexp = @cache[pattern] +      regexp +    else +      @cache[pattern] = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$") + +# The Exclusions class manages the exclusion rule setting. +# An exclusion is an object with two attributes: pattern and passKeys. +# The exclusions are an array of such objects (because the order matters). + +root.Exclusions = Exclusions = + +  rules: Settings.get("exclusionRules") + +  # Return the first exclusion rule matching the URL, or null. +  getRule: (url) -> +    for rule in @rules +      return rule if url.match(RegexpCache.get(rule.pattern)) +    return null + +  setRules: (rules) -> +    # Callers map a rule to null to have it deleted, and rules without a pattern are useless. +    @rules = rules.filter (rule) -> rule and rule.pattern +    Settings.set("exclusionRules", @rules) + +  postUpdateHook: (rules) -> +    @rules = rules + +  # Update an existing rule or add a new rule. +  updateOrAdd: (newRule) -> +    seen = false +    @rules.push(newRule) +    @setRules @rules.map (rule) -> +      if rule.pattern == newRule.pattern +        if seen then null else seen = newRule +      else +        rule + +  remove: (pattern) -> +    @setRules(@rules.filter((rule) -> rule and rule.pattern != pattern)) + +# Development and debug only. +# Enable this (temporarily) to restore legacy exclusion rules from backup. +if false and Settings.has("excludedUrlsBackup") +  Settings.clear("exclusionRules") +  Settings.set("excludedUrls", Settings.get("excludedUrlsBackup")) + +if not Settings.has("exclusionRules") and Settings.has("excludedUrls") +  # Migration from the legacy representation of exclusion rules. +  # +  # In Vimium 1.45 and in github/master on 27 August, 2014, exclusion rules are represented by the setting: +  #   excludedUrls: "http*://www.google.com/reader/*\nhttp*://mail.google.com/* jk" +  # +  # The new (equivalent) settings is: +  #   exclusionRules: [ { pattern: "http*://www.google.com/reader/*", passKeys: "" }, { pattern: "http*://mail.google.com/*", passKeys: "jk" } ] + +  parseLegacyRules = (lines) -> +    for line in lines.trim().split("\n").map((line) -> line.trim()) +      if line.length and line.indexOf("#") != 0 and line.indexOf('"') != 0 +        parse = line.split(/\s+/) +        { pattern: parse[0], passKeys: parse[1..].join("") } + +  Exclusions.setRules(parseLegacyRules(Settings.get("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") diff --git a/background_scripts/main.coffee b/background_scripts/main.coffee index f564f477..898f46f1 100644 --- a/background_scripts/main.coffee +++ b/background_scripts/main.coffee @@ -24,9 +24,11 @@ completionSources =    history: new HistoryCompleter()    domains: new DomainCompleter()    tabs: new TabCompleter() +  seachEngines: new SearchEngineCompleter()  completers =    omni: new MultiCompleter([ +    completionSources.seachEngines,      completionSources.bookmarks,      completionSources.history,      completionSources.domains]) @@ -67,30 +69,31 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) ->  getCurrentTabUrl = (request, sender) -> sender.tab.url  # -# Checks the user's preferences in local storage to determine if Vimium is enabled for the given 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.  # -isEnabledForUrl = (request) -> -  # excludedUrls are stored as a series of URL expressions separated by newlines. -  excludedUrls = Settings.get("excludedUrls").split("\n") -  isEnabled = true -  for url in excludedUrls -    # The user can add "*" to the URL which means ".*" -    regexp = new RegExp("^" + url.replace(/\*/g, ".*") + "$") -    isEnabled = false if request.url.match(regexp) -  { isEnabledForUrl: isEnabled } - -# Called by the popup UI. Strips leading/trailing whitespace and ignores empty strings. -root.addExcludedUrl = (url) -> -  return unless url = url.trim() - -  excludedUrls = Settings.get("excludedUrls") -  return if excludedUrls.indexOf(url) >= 0 - -  excludedUrls += "\n" + url -  Settings.set("excludedUrls", excludedUrls) - -  chrome.tabs.query({ windowId: chrome.windows.WINDOW_ID_CURRENT, active: true }, -    (tabs) -> updateActiveState(tabs[0].id)) +root.isEnabledForUrl = isEnabledForUrl = (request) -> +  rule = Exclusions.getRule(request.url) +  { +    rule: rule +    isEnabledForUrl: not rule or rule.passKeys +    passKeys: rule?.passKeys or "" +  } + +# Called by the popup UI. +# If the URL pattern matches an existing rule, then the existing rule is updated. Otherwise, a new rule is created. +root.addExclusionRule = (pattern,passKeys) -> +  if pattern = pattern.trim() +    Exclusions.updateOrAdd({ pattern: pattern, passKeys: passKeys }) +    chrome.tabs.query({ windowId: chrome.windows.WINDOW_ID_CURRENT, active: true }, +      (tabs) -> updateActiveState(tabs[0].id)) + +# Called by the popup UI.  Remove all existing exclusion rules with this pattern. +root.removeExclusionRule = (pattern) -> +  if pattern = pattern.trim() +    Exclusions.remove(pattern) +    chrome.tabs.query({ windowId: chrome.windows.WINDOW_ID_CURRENT, active: true }, +      (tabs) -> updateActiveState(tabs[0].id))  saveHelpDialogSettings = (request) ->    Settings.set("helpDialog_showAdvancedCommands", request.showAdvancedCommands) @@ -217,6 +220,12 @@ repeatFunction = (func, totalCount, currentCount, frameId) ->        -> repeatFunction(func, totalCount, currentCount + 1, frameId),        frameId) +moveTab = (callback, direction) -> +  chrome.tabs.getSelected(null, (tab) -> +    # Use Math.max to prevent -1 as the new index, otherwise the tab of index n will wrap to the far RHS when +    # moved left by exactly (n+1) places. +    chrome.tabs.move(tab.id, {index: Math.max(0, tab.index + direction) }, callback)) +  # Start action functions  # These are commands which are bound to keystroke which must be handled by the background page. They are @@ -228,39 +237,51 @@ BackgroundCommands =        chrome.tabs.duplicate(tab.id)        selectionChangedHandlers.push(callback))    moveTabToNewWindow: (callback) -> -    chrome.tabs.getSelected(null, (tab) -> -      chrome.windows.create({tabId: tab.id})) +    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") -  removeTab: -> +  removeTab: (callback) ->      chrome.tabs.getSelected(null, (tab) -> -      chrome.tabs.remove(tab.id)) +      chrome.tabs.remove(tab.id) +      selectionChangedHandlers.push(callback))    restoreTab: (callback) -> -    # TODO(ilya): Should this be getLastFocused instead? -    chrome.windows.getCurrent((window) -> -      return unless (tabQueue[window.id] && tabQueue[window.id].length > 0) -      tabQueueEntry = tabQueue[window.id].pop() -      # Clean out the tabQueue so we don't have unused windows laying about. -      delete tabQueue[window.id] if (tabQueue[window.id].length == 0) - -      # We have to chain a few callbacks to set the appropriate scroll position. We can't just wait until the -      # tab is created because the content script is not available during the "loading" state. We need to -      # wait until that's over before we can call setScrollPosition. -      chrome.tabs.create({ url: tabQueueEntry.url, index: tabQueueEntry.positionIndex }, (tab) -> -        tabLoadedHandlers[tab.id] = -> -          chrome.tabs.sendMessage(tab.id, -            name: "setScrollPosition", -            scrollX: tabQueueEntry.scrollX, -            scrollY: tabQueueEntry.scrollY) -        callback())) +    # TODO: remove if-else -block when adopted into stable +    if chrome.sessions +      chrome.sessions.restore(null, (restoredSession) -> +          callback() unless chrome.runtime.lastError) +    else +      # TODO(ilya): Should this be getLastFocused instead? +      chrome.windows.getCurrent((window) -> +        return unless (tabQueue[window.id] && tabQueue[window.id].length > 0) +        tabQueueEntry = tabQueue[window.id].pop() +        # Clean out the tabQueue so we don't have unused windows laying about. +        delete tabQueue[window.id] if (tabQueue[window.id].length == 0) + +        # We have to chain a few callbacks to set the appropriate scroll position. We can't just wait until the +        # tab is created because the content script is not available during the "loading" state. We need to +        # wait until that's over before we can call setScrollPosition. +        chrome.tabs.create({ url: tabQueueEntry.url, index: tabQueueEntry.positionIndex }, (tab) -> +          tabLoadedHandlers[tab.id] = -> +            chrome.tabs.sendRequest(tab.id, +              name: "setScrollPosition", +              scrollX: tabQueueEntry.scrollX, +              scrollY: tabQueueEntry.scrollY) +          callback()))    openCopiedUrlInCurrentTab: (request) -> openUrlInCurrentTab({ url: Clipboard.paste() })    openCopiedUrlInNewTab: (request) -> openUrlInNewTab({ url: Clipboard.paste() }) +  togglePinTab: (request) -> +    chrome.tabs.getSelected(null, (tab) -> +      chrome.tabs.update(tab.id, { pinned: !tab.pinned }))    showHelp: (callback, frameId) ->      chrome.tabs.getSelected(null, (tab) ->        chrome.tabs.sendMessage(tab.id,          { name: "toggleHelpDialog", dialogHtml: helpDialogHtml(), frameId:frameId })) +  moveTabLeft: (count) -> moveTab(null, -count) +  moveTabRight: (count) -> moveTab(null, count)    nextFrame: (count) ->      chrome.tabs.getSelected(null, (tab) ->        frames = framesForTab[tab.id].frames @@ -272,6 +293,30 @@ BackgroundCommands =        chrome.tabs.sendMessage(tab.id, { name: "focusFrame", frameId: frames[newIndex].id, highlight: true })) +  closeTabsOnLeft: -> removeTabsRelative "before" +  closeTabsOnRight: -> removeTabsRelative "after" +  closeOtherTabs: -> removeTabsRelative "both" + +# Remove tabs before, after, or either side of the currently active tab +removeTabsRelative = (direction) -> +  chrome.tabs.query {currentWindow: true}, (tabs) -> +    chrome.tabs.query {currentWindow: true, active: true}, (activeTabs) -> +      activeTabIndex = activeTabs[0].index + +      shouldDelete = switch direction +        when "before" +          (index) -> index < activeTabIndex +        when "after" +          (index) -> index > activeTabIndex +        when "both" +          (index) -> index != activeTabIndex + +      toRemove = [] +      for tab in tabs +        if not tab.pinned and shouldDelete tab.index +          toRemove.push tab.id +      chrome.tabs.remove toRemove +  # Selects a tab before or after the currently selected tab.  # - direction: "next", "previous", "first" or "last".  selectTab = (callback, direction) -> @@ -304,32 +349,36 @@ updateOpenTabs = (tab) ->    # Frames are recreated on refresh    delete framesForTab[tab.id] -# Updates the browserAction icon to indicated whether Vimium is enabled or disabled on the current page. -# Also disables Vimium if it is currently enabled but should be disabled according to the url blacklist. +setBrowserActionIcon = (tabId,path) -> +  chrome.browserAction.setIcon({ tabId: tabId, path: path }) + +# 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. -# -# Three situations are considered: -# 1. Active tab is disabled -> disable icon -# 2. Active tab is enabled and should be enabled -> enable icon -# 3. Active tab is enabled but should be disabled -> disable icon and disable vimium  updateActiveState = (tabId) ->    enabledIcon = "icons/browser_action_enabled.png"    disabledIcon = "icons/browser_action_disabled.png" -  chrome.tabs.get(tabId, (tab) -> -    # Default to disabled state in case we can't connect to Vimium, primarily for the "New Tab" page. -    chrome.browserAction.setIcon({ path: disabledIcon }) -    chrome.tabs.sendMessage(tabId, { name: "getActiveState" }, (response) -> -      isCurrentlyEnabled = (response? && response.enabled) -      shouldBeEnabled = isEnabledForUrl({url: tab.url}).isEnabledForUrl - -      if (isCurrentlyEnabled) -        if (shouldBeEnabled) -          chrome.browserAction.setIcon({ path: enabledIcon }) +  partialIcon = "icons/browser_action_partial.png" +  chrome.tabs.get tabId, (tab) -> +    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 -          chrome.browserAction.setIcon({ path: disabledIcon }) -          chrome.tabs.sendMessage(tabId, { name: "disableVimium" }) +          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 })        else -        chrome.browserAction.setIcon({ path: disabledIcon }))) +        # We didn't get a response from the front end, so Vimium isn't running. +        setBrowserActionIcon(tabId,disabledIcon)  handleUpdateScrollPosition = (request, sender) ->    updateScrollPosition(sender.tab, request.scrollX, request.scrollY) @@ -340,12 +389,14 @@ updateScrollPosition = (tab, scrollX, scrollY) ->  chrome.tabs.onUpdated.addListener (tabId, changeInfo, tab) ->    return unless changeInfo.status == "loading" # only do this once per URL change -  chrome.tabs.insertCSS tabId, +  cssConf =      allFrames: true      code: Settings.get("userDefinedLinkHintCss")      runAt: "document_start" -  updateOpenTabs(tab) -  updateActiveState(tabId) +  chrome.tabs.insertCSS tabId, cssConf, -> +    if not chrome.runtime.lastError +      updateOpenTabs(tab) +      updateActiveState(tabId)  chrome.tabs.onAttached.addListener (tabId, attachedInfo) ->    # We should update all the tabs in the old window and the new window. @@ -363,16 +414,17 @@ chrome.tabs.onRemoved.addListener (tabId) ->    # If we restore pages that content scripts can't run on, they'll ignore Vimium keystrokes when they    # reappear. Pretend they never existed and adjust tab indices accordingly. Could possibly expand this into    # a blacklist in the future. -  if (/^(chrome|view-source:)[^:]*:\/\/.*/.test(openTabInfo.url)) -    for i of tabQueue[openTabInfo.windowId] -      if (tabQueue[openTabInfo.windowId][i].positionIndex > openTabInfo.positionIndex) -        tabQueue[openTabInfo.windowId][i].positionIndex-- -    return - -  if (tabQueue[openTabInfo.windowId]) -    tabQueue[openTabInfo.windowId].push(openTabInfo) -  else -    tabQueue[openTabInfo.windowId] = [openTabInfo] +  unless chrome.sessions +    if (/^(chrome|view-source:)[^:]*:\/\/.*/.test(openTabInfo.url)) +      for i of tabQueue[openTabInfo.windowId] +        if (tabQueue[openTabInfo.windowId][i].positionIndex > openTabInfo.positionIndex) +          tabQueue[openTabInfo.windowId][i].positionIndex-- +      return + +    if (tabQueue[openTabInfo.windowId]) +      tabQueue[openTabInfo.windowId].push(openTabInfo) +    else +      tabQueue[openTabInfo.windowId] = [openTabInfo]    # keep the reference around for a while to wait for the last messages from the closed tab (e.g. for updating    # scroll position) @@ -382,7 +434,8 @@ chrome.tabs.onRemoved.addListener (tabId) ->  chrome.tabs.onActiveChanged.addListener (tabId, selectInfo) -> updateActiveState(tabId) -chrome.windows.onRemoved.addListener (windowId) -> delete tabQueue[windowId] +unless chrome.sessions +  chrome.windows.onRemoved.addListener (windowId) -> delete tabQueue[windowId]  # End action functions @@ -458,6 +511,14 @@ handleKeyDown = (request, port) ->      console.log("checking keyQueue: [", keyQueue + key, "]")      keyQueue = checkKeyQueue(keyQueue + key, port.sender.tab.id, request.frameId)      console.log("new KeyQueue: " + keyQueue) +  # Tell the content script whether there are keys in the queue. +  # FIXME: There is a race condition here.  The behaviour in the content script depends upon whether this message gets +  # back there before or after the next keystroke. +  # That being said, I suspect there are other similar race conditions here, for example in checkKeyQueue(). +  # Steve (23 Aug, 14). +  chrome.tabs.sendMessage(port.sender.tab.id, +    name: "currentKeyQueue", +    keyQueue: keyQueue)  checkKeyQueue = (keysToCheck, tabId, frameId) ->    refreshedCompletionKeys = false @@ -470,23 +531,35 @@ checkKeyQueue = (keysToCheck, tabId, frameId) ->    if (Commands.keyToCommandRegistry[command])      registryEntry = Commands.keyToCommandRegistry[command] - -    if !registryEntry.isBackgroundCommand -      chrome.tabs.sendMessage(tabId, -        name: "executePageCommand", -        command: registryEntry.command, -        frameId: frameId, -        count: count, -        passCountToFunction: registryEntry.passCountToFunction, -        completionKeys: generateCompletionKeys("")) -      refreshedCompletionKeys = true -    else -      if registryEntry.passCountToFunction -        BackgroundCommands[registryEntry.command](count) -      else if registryEntry.noRepeat -        BackgroundCommands[registryEntry.command]() +    runCommand = true + +    if registryEntry.noRepeat +      count = 1 +    else if registryEntry.repeatLimit and count > registryEntry.repeatLimit +      runCommand = confirm """ +        You have asked Vimium to perform #{count} repeats of the command: +        #{Commands.availableCommands[registryEntry.command].description} + +        Are you sure you want to continue? +      """ + +    if runCommand +      if not registryEntry.isBackgroundCommand +        chrome.tabs.sendMessage(tabId, +          name: "executePageCommand", +          command: registryEntry.command, +          frameId: frameId, +          count: count, +          passCountToFunction: registryEntry.passCountToFunction, +          completionKeys: generateCompletionKeys("")) +        refreshedCompletionKeys = true        else -        repeatFunction(BackgroundCommands[registryEntry.command], count, 0, frameId) +        if registryEntry.passCountToFunction +          BackgroundCommands[registryEntry.command](count) +        else if registryEntry.noRepeat +          BackgroundCommands[registryEntry.command]() +        else +          repeatFunction(BackgroundCommands[registryEntry.command], count, 0, frameId)      newKeyQueue = ""    else if (getActualKeyStrokeLength(command) > 1) @@ -538,7 +611,7 @@ registerFrame = (request, sender) ->      focusedFrame = request.frameId      framesForTab[sender.tab.id].total = request.total -  framesForTab[sender.tab.id].frames.push({ id: request.frameId, area: request.area }) +  framesForTab[sender.tab.id].frames.push({ id: request.frameId })  handleFrameFocused = (request, sender) -> focusedFrame = request.frameId @@ -596,3 +669,6 @@ chrome.windows.getAll { populate: true }, (windows) ->        createScrollPositionHandler = ->          (response) -> updateScrollPosition(tab, response.scrollX, response.scrollY) if response?        chrome.tabs.sendMessage(tab.id, { name: "getScrollPosition" }, createScrollPositionHandler()) + +# Start pulling changes from synchronized storage. +Sync.init() diff --git a/background_scripts/settings.coffee b/background_scripts/settings.coffee index 0fe1e1bb..7150fcba 100644 --- a/background_scripts/settings.coffee +++ b/background_scripts/settings.coffee @@ -1,5 +1,5 @@  # -# Used by everyone to manipulate localStorage. +# Used by all parts of Vimium to manipulate localStorage.  #  root = exports ? window @@ -8,20 +8,60 @@ root.Settings = Settings =      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 +    # 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 -      localStorage[key] = JSON.stringify(value) +      jsonValue = JSON.stringify value +      localStorage[key] = jsonValue +      Sync.set key, jsonValue -  clear: (key) -> delete localStorage[key] +  clear: (key) -> +    if @has key +      delete localStorage[key] +    Sync.clear key    has: (key) -> key of localStorage -  # options/options.(coffee|html) only handle booleans and strings; therefore -  # all defaults must be booleans or strings +  # 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: {} + +  # this parses the search engines settings and clears the old searchEngines and sets the new one +  parseSearchEngines: (searchEnginesText) -> +    @searchEnginesMap = {} +    # find the split pairs by first splitting by line then splitting on the first `: ` +    split_pairs = ( pair.split( /: (.+)/, 2) for pair in searchEnginesText.split( /\n/ ) when pair[0] != "#" ) +    @searchEnginesMap[a[0]] = a[1] for a in split_pairs +    @searchEnginesMap +  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 +    keyMappings: "# Insert your prefered key mappings here."      linkHintCharacters: "sadfjklewcmpgh"      linkHintNumbers: "0123456789"      filterLinkHints: false @@ -45,11 +85,14 @@ root.Settings = Settings =        div > .vimiumHintMarker > .matchingCharacter {        }        """ -    excludedUrls: -      """ -      http*://mail.google.com/* -      """ -    # NOTE : If a page contains both a single angle-bracket link and a double angle-bracket link, then in +    # 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. @@ -60,9 +103,12 @@ root.Settings = Settings =      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"      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")) diff --git a/background_scripts/sync.coffee b/background_scripts/sync.coffee new file mode 100644 index 00000000..93430856 --- /dev/null +++ b/background_scripts/sync.coffee @@ -0,0 +1,102 @@ +# +# * 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 = + +  # April 19 2014: Leave logging statements in, but disable debugging. We may need to come back to this, so +  # removing logging now would be premature. However, if users report problems, they are unlikely to notice +  # and make sense of console logs on background pages. So disable it, by default. For genuine errors, we +  # call console.log directly. +  debug: false +  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) => +      # Chrome sets chrome.runtime.lastError if there is an error. +      if chrome.runtime.lastError is undefined +        for own key, value of items +          @log "fetchAsync: #{key} <- #{value}" +          @storeAndPropagate key, value +      else +        console.log "callback for Sync.fetchAsync() indicates error" +        console.log chrome.runtime.lastError + +  # Asynchronous message from synced storage. +  handleStorageUpdate: (changes, area) -> +    for own key, change of changes +      @log "handleStorageUpdate: #{key} <- #{change.newValue}" +      @storeAndPropagate key, change?.newValue + +  # Only ever called from asynchronous synced-storage callbacks (fetchAsync and handleStorageUpdate). +  storeAndPropagate: (key, value) -> +    return if not 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. +      @log "storeAndPropagate update: #{key}=#{value}" +      localStorage[key] = value +      Settings.performPostUpdateHook key, JSON.parse(value) +    else +      # Key has been reset to default value at remote instance. +      @log "storeAndPropagate clear: #{key}" +      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 +      @log "set scheduled: #{key}=#{value}" +      key_value = {} +      key_value[key] = value +      @storage.set key_value, => +        # Chrome sets chrome.runtime.lastError if there is an error. +        if chrome.runtime.lastError +          console.log "callback for Sync.set() indicates error: #{key} <- #{value}" +          console.log chrome.runtime.lastError + +  # Only called synchronously from within vimium, never on a callback. +  clear: (key) -> +    if @shouldSyncKey key +      @log "clear scheduled: #{key}" +      @storage.remove key, => +        # Chrome sets chrome.runtime.lastError if there is an error. +        if chrome.runtime.lastError +          console.log "for Sync.clear() indicates error: #{key}" +          console.log chrome.runtime.lastError + +  # Should we synchronize this key? +  shouldSyncKey: (key) -> +    key not in @doNotSync + +  log: (msg) -> +    console.log "Sync: #{msg}" if @debug | 
