aboutsummaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/dom_utils.coffee94
-rw-r--r--lib/handler_stack.coffee6
-rw-r--r--lib/settings.coffee202
-rw-r--r--lib/utils.coffee192
4 files changed, 462 insertions, 32 deletions
diff --git a/lib/dom_utils.coffee b/lib/dom_utils.coffee
index 2ae9412e..7c47179c 100644
--- a/lib/dom_utils.coffee
+++ b/lib/dom_utils.coffee
@@ -27,6 +27,12 @@ DomUtils =
removeElement: (el) -> el.parentNode.removeChild el
#
+ # Test whether the current frame is the top/main frame.
+ #
+ isTopFrame: ->
+ window.top == window.self
+
+ #
# Takes an array of XPath selectors, adds the necessary namespaces (currently only XHTML), and applies them
# to the document root. The namespaceResolver in evaluateXPath should be kept in sync with the namespaces
# here.
@@ -49,21 +55,37 @@ DomUtils =
#
# Returns the first visible clientRect of an element if it exists. Otherwise it returns null.
#
- getVisibleClientRect: (element) ->
+ # WARNING: If testChildren = true then the rects of visible (eg. floated) children may be returned instead.
+ # This is used for LinkHints and focusInput, **BUT IS UNSUITABLE FOR MOST OTHER PURPOSES**.
+ #
+ getVisibleClientRect: (element, testChildren = false) ->
# Note: this call will be expensive if we modify the DOM in between calls.
clientRects = (Rect.copy clientRect for clientRect in element.getClientRects())
+ # Inline elements with font-size: 0px; will declare a height of zero, even if a child with non-zero
+ # font-size contains text.
+ isInlineZeroHeight = ->
+ elementComputedStyle = window.getComputedStyle element, null
+ isInlineZeroFontSize = (0 == elementComputedStyle.getPropertyValue("display").indexOf "inline") and
+ (elementComputedStyle.getPropertyValue("font-size") == "0px")
+ # Override the function to return this value for the rest of this context.
+ isInlineZeroHeight = -> isInlineZeroFontSize
+ isInlineZeroFontSize
+
for clientRect in clientRects
- # If the link has zero dimensions, it may be wrapping visible
- # but floated elements. Check for this.
- if (clientRect.width == 0 || clientRect.height == 0)
+ # If the link has zero dimensions, it may be wrapping visible but floated elements. Check for this.
+ if (clientRect.width == 0 or clientRect.height == 0) and testChildren
for child in element.children
computedStyle = window.getComputedStyle(child, null)
- # Ignore child elements which are not floated and not absolutely positioned for parent elements with
- # zero width/height
- continue if (computedStyle.getPropertyValue('float') == 'none' &&
- computedStyle.getPropertyValue('position') != 'absolute')
- childClientRect = @getVisibleClientRect(child)
+ # Ignore child elements which are not floated and not absolutely positioned for parent elements
+ # with zero width/height, as long as the case described at isInlineZeroHeight does not apply.
+ # NOTE(mrmr1993): This ignores floated/absolutely positioned descendants nested within inline
+ # children.
+ continue if (computedStyle.getPropertyValue("float") == "none" and
+ computedStyle.getPropertyValue("position") != "absolute" and
+ not (clientRect.height == 0 and isInlineZeroHeight() and
+ 0 == computedStyle.getPropertyValue("display").indexOf "inline"))
+ childClientRect = @getVisibleClientRect child, true
continue if childClientRect == null or childClientRect.width < 3 or childClientRect.height < 3
return childClientRect
@@ -74,9 +96,7 @@ DomUtils =
# eliminate invisible elements (see test_harnesses/visibility_test.html)
computedStyle = window.getComputedStyle(element, null)
- if (computedStyle.getPropertyValue('visibility') != 'visible' ||
- computedStyle.getPropertyValue('display') == 'none')
- continue
+ continue if computedStyle.getPropertyValue('visibility') != 'visible'
return clientRect
@@ -167,15 +187,20 @@ DomUtils =
node = node.parentNode
false
- # True if element contains the active selection range.
+ # True if element is editable and contains the active selection range.
isSelected: (element) ->
+ selection = document.getSelection()
if element.isContentEditable
- node = document.getSelection()?.anchorNode
+ node = selection.anchorNode
node and @isDOMDescendant element, node
else
- # Note. This makes the wrong decision if the user has placed the caret at the start of element. We
- # cannot distinguish that case from the user having made no selection.
- element.selectionStart? and element.selectionEnd? and element.selectionEnd != 0
+ if selection.type == "Range" and selection.isCollapsed
+ # The selection is inside the Shadow DOM of a node. We can check the node it registers as being
+ # before, since this represents the node whose Shadow DOM it's inside.
+ containerNode = selection.anchorNode.childNodes[selection.anchorOffset]
+ element == containerNode # True if the selection is inside the Shadow DOM of our element.
+ else
+ false
simulateSelect: (element) ->
# If element is already active, then we don't move the selection. However, we also won't get a new focus
@@ -185,11 +210,17 @@ DomUtils =
handlerStack.bubbleEvent "click", target: element
else
element.focus()
- unless @isSelected element
- # When focusing a textbox (without an existing selection), put the selection caret at the end of the
- # textbox's contents. For some HTML5 input types (eg. date) we can't position the caret, so we wrap
- # this with a try.
- try element.setSelectionRange(element.value.length, element.value.length)
+ # If the cursor is at the start of the element's contents, send it to the end. Motivation:
+ # * the end is a more useful place to focus than the start,
+ # * this way preserves the last used position (except when it's at the beginning), so the user can
+ # 'resume where they left off'.
+ # NOTE(mrmr1993): Some elements throw an error when we try to access their selection properties, so
+ # wrap this with a try.
+ try
+ if element.selectionStart == 0 and element.selectionEnd == 0
+ element.setSelectionRange element.value.length, element.value.length
+
+
simulateClick: (element, modifiers) ->
modifiers ||= {}
@@ -295,5 +326,24 @@ DomUtils =
document.body.removeChild div
coordinates
+ # Get the text content of an element (and its descendents), but omit the text content of previously-visited
+ # nodes. See #1514.
+ # NOTE(smblott). This is currently O(N^2) (when called on N elements). An alternative would be to mark
+ # each node visited, and then clear the marks when we're done.
+ textContent: do ->
+ visitedNodes = null
+ reset: -> visitedNodes = []
+ get: (element) ->
+ nodes = document.createTreeWalker element, NodeFilter.SHOW_TEXT
+ texts =
+ while node = nodes.nextNode()
+ continue unless node.nodeType == 3
+ continue if node in visitedNodes
+ text = node.data.trim()
+ continue unless 0 < text.length
+ visitedNodes.push node
+ text
+ texts.join " "
+
root = exports ? window
root.DomUtils = DomUtils
diff --git a/lib/handler_stack.coffee b/lib/handler_stack.coffee
index b0fefc7d..b09d3183 100644
--- a/lib/handler_stack.coffee
+++ b/lib/handler_stack.coffee
@@ -84,10 +84,8 @@ class HandlerStack
# Debugging.
logResult: (eventNumber, type, event, handler, result) ->
- # FIXME(smblott). Badge updating is too noisy, so we filter it out. However, we do need to look at how
- # many badge update events are happening. It seems to be more than necessary. We also filter out
- # registerKeyQueue as unnecessarily noisy and not particularly helpful.
- return if type in [ "updateBadge", "registerKeyQueue" ]
+ # Key queue events aren't usually useful for debugging, so we filter them out.
+ return if type in [ "registerKeyQueue" ]
label =
switch result
when @stopBubblingAndTrue then "stop/true"
diff --git a/lib/settings.coffee b/lib/settings.coffee
new file mode 100644
index 00000000..dd667dbd
--- /dev/null
+++ b/lib/settings.coffee
@@ -0,0 +1,202 @@
+#
+# * 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.
+#
+# 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
+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
+ Settings.storeAndPropagate key, value if @shouldSyncKey key
+
+ # Asynchronous message from synced storage.
+ handleStorageUpdate: (changes, area) ->
+ for own key, change of changes
+ Settings.storeAndPropagate key, change?.newValue if @shouldSyncKey key
+
+ # 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
+
+#
+# Used by all parts of Vimium to manipulate localStorage.
+#
+
+# Select the object to use as the cache for settings.
+if Utils.isExtensionPage()
+ if Utils.isBackgroundPage()
+ settingsCache = localStorage
+ else
+ settingsCache = extend {}, localStorage # Make a copy of the cached settings from localStorage
+else
+ settingsCache = {}
+
+root.Settings = Settings =
+ cache: settingsCache
+ init: -> Sync.init()
+ get: (key) ->
+ if (key of @cache) then JSON.parse(@cache[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
+ @cache[key] = jsonValue
+ Sync.set key, jsonValue
+
+ clear: (key) ->
+ if @has key
+ delete @cache[key]
+ Sync.clear key
+
+ has: (key) -> key of @cache
+
+ # For settings which require action when their value changes, add hooks to this object, to be called from
+ # options/options.coffee (when the options page is saved), and by Settings.storeAndPropagate (when an
+ # update propagates from chrome.storage.sync).
+ postUpdateHooks: {}
+
+ # postUpdateHooks convenience wrapper
+ performPostUpdateHook: (key, value) ->
+ @postUpdateHooks[key]? value
+
+ # Only ever called from asynchronous synced-storage callbacks (fetchAsync and handleStorageUpdate).
+ storeAndPropagate: (key, value) ->
+ return unless key of @defaults
+ return if value and key of @cache and @cache[key] is value
+ defaultValue = @defaults[key]
+ defaultValueJSON = JSON.stringify(defaultValue)
+
+ if value and value != defaultValueJSON
+ # Key/value has been changed to non-default value at remote instance.
+ @cache[key] = value
+ @performPostUpdateHook key, JSON.parse(value)
+ else
+ # Key has been reset to default value at remote instance.
+ if key of @cache
+ delete @cache[key]
+ @performPostUpdateHook key, defaultValue
+
+ # 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: "https://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"
+ ""
+ "# More examples."
+ "#"
+ "# (Vimium has built-in completion for these.)"
+ "#"
+ "# g: http://www.google.com/search?q=%s Google"
+ "# l: http://www.google.com/search?q=%s&btnI I'm feeling lucky..."
+ "# y: http://www.youtube.com/results?search_query=%s Youtube"
+ "# b: https://www.bing.com/search?q=%s Bing"
+ "# d: https://duckduckgo.com/?q=%s DuckDuckGo"
+ "# az: http://www.amazon.com/s/?field-keywords=%s Amazon"
+ "#"
+ "# Another example (for Vimium does not have completion)."
+ "#"
+ "# m: https://www.google.com/maps/search/%s Google Maps"
+ ].join "\n"
+ newTabUrl: "chrome://newtab"
+ grabBackFocus: false
+
+ settingsVersion: Utils.getCurrentVersion()
+
+# Export Sync via Settings for tests.
+root.Settings.Sync = Sync
+
+# Perform migration from old settings versions, if this is the background page.
+if Utils.isBackgroundPage()
+
+ # 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/lib/utils.coffee b/lib/utils.coffee
index 64c87842..93045f32 100644
--- a/lib/utils.coffee
+++ b/lib/utils.coffee
@@ -2,6 +2,13 @@ Utils =
getCurrentVersion: ->
chrome.runtime.getManifest().version
+ # Returns true whenever the current page is from the extension's origin (and thus can access the
+ # extension's localStorage).
+ isExtensionPage: -> document.location?.origin + "/" == chrome.extension.getURL ""
+
+ # Returns true whenever the current page is the extension's background page.
+ isBackgroundPage: -> @isExtensionPage() and chrome.extension.getBackgroundPage() == window
+
# Takes a dot-notation object string and call the function
# that it points to with the correct value for 'this'.
invokeCommandString: (str, argArray) ->
@@ -26,16 +33,30 @@ Utils =
-> id += 1
hasChromePrefix: do ->
- chromePrefixes = [ "about:", "view-source:", "extension:", "chrome-extension:", "data:", "javascript:" ]
+ chromePrefixes = [ "about:", "view-source:", "extension:", "chrome-extension:", "data:" ]
(url) ->
for prefix in chromePrefixes
return true if url.startsWith prefix
false
+ hasJavascriptPrefix: (url) ->
+ url.startsWith "javascript:"
+
hasFullUrlPrefix: do ->
urlPrefix = new RegExp "^[a-z]{3,}://."
(url) -> urlPrefix.test url
+ # Decode valid escape sequences in a URI. This is intended to mimic the best-effort decoding
+ # Chrome itself seems to apply when a Javascript URI is enetered into the omnibox (or clicked).
+ # See https://code.google.com/p/chromium/issues/detail?id=483000, #1611 and #1636.
+ decodeURIByParts: (uri) ->
+ uri.split(/(?=%)/).map((uriComponent) ->
+ try
+ decodeURIComponent uriComponent
+ catch
+ uriComponent
+ ).join ""
+
# Completes a partial URL (without scheme)
createFullUrl: (partialUrl) ->
if @hasFullUrlPrefix(partialUrl) then partialUrl else ("http://" + partialUrl)
@@ -93,11 +114,32 @@ Utils =
query = query.split(/\s+/) if typeof(query) == "string"
query.map(encodeURIComponent).join "+"
- # Creates a search URL from the given :query.
- createSearchUrl: (query) ->
- # It would be better to pull the default search engine from chrome itself. However, unfortunately chrome
- # does not provide an API for doing so.
- Settings.get("searchUrl") + @createSearchQuery query
+ # Create a search URL from the given :query (using either the provided search URL, or the default one).
+ # It would be better to pull the default search engine from chrome itself. However, chrome does not provide
+ # an API for doing so.
+ createSearchUrl: (query, searchUrl = Settings.get("searchUrl")) ->
+ searchUrl += "%s" unless 0 <= searchUrl.indexOf "%s"
+ searchUrl.replace /%s/g, @createSearchQuery query
+
+ # Extract a query from url if it appears to be a URL created from the given search URL.
+ # For example, map "https://www.google.ie/search?q=star+wars&foo&bar" to "star wars".
+ extractQuery: do =>
+ queryTerminator = new RegExp "[?&#/]"
+ httpProtocolRegexp = new RegExp "^https?://"
+ (searchUrl, url) ->
+ url = url.replace httpProtocolRegexp
+ searchUrl = searchUrl.replace httpProtocolRegexp
+ [ searchUrl, suffixTerms... ] = searchUrl.split "%s"
+ # We require the URL to start with the search URL.
+ return null unless url.startsWith searchUrl
+ # We require any remaining terms in the search URL to also be present in the URL.
+ for suffix in suffixTerms
+ return null unless 0 <= url.indexOf suffix
+ # We use try/catch because decodeURIComponent can throw an exception.
+ try
+ url[searchUrl.length..].split(queryTerminator)[0].split("+").map(decodeURIComponent).join " "
+ catch
+ null
# Converts :string into a Google search if it's not already a URL. We don't bother with escaping characters
# as Chrome will do that for us.
@@ -107,6 +149,8 @@ Utils =
# Special-case about:[url], view-source:[url] and the like
if Utils.hasChromePrefix string
string
+ else if Utils.hasJavascriptPrefix string
+ Utils.decodeURIByParts string
else if Utils.isUrl string
Utils.createFullUrl string
else
@@ -169,6 +213,61 @@ Utils =
delete obj[property] for property in properties
obj
+ # Does string match any of these regexps?
+ matchesAnyRegexp: (regexps, string) ->
+ for re in regexps
+ return true if re.test string
+ false
+
+ # Calculate the length of the longest shared prefix of a list of strings.
+ longestCommonPrefix: (strings) ->
+ return 0 unless 0 < strings.length
+ strings.sort (a,b) -> a.length - b.length
+ [ shortest, strings... ] = strings
+ for ch, index in shortest.split ""
+ for str in strings
+ return index if ch != str[index]
+ return shortest.length
+
+ # Convenience wrapper for setTimeout (with the arguments around the other way).
+ setTimeout: (ms, func) -> setTimeout func, ms
+
+ # Like Nodejs's nextTick.
+ nextTick: (func) -> @setTimeout 0, func
+
+# Utility for parsing and using the custom search-engine configuration. We re-use the previous parse if the
+# search-engine configuration is unchanged.
+SearchEngines =
+ previousSearchEngines: null
+ searchEngines: null
+
+ refresh: (searchEngines) ->
+ unless @previousSearchEngines? and searchEngines == @previousSearchEngines
+ @previousSearchEngines = searchEngines
+ @searchEngines = new AsyncDataFetcher (callback) ->
+ engines = {}
+ for line in searchEngines.split "\n"
+ line = line.trim()
+ continue if /^[#"]/.test line
+ tokens = line.split /\s+/
+ continue unless 2 <= tokens.length
+ keyword = tokens[0].split(":")[0]
+ searchUrl = tokens[1]
+ description = tokens[2..].join(" ") || "search (#{keyword})"
+ continue unless Utils.hasFullUrlPrefix searchUrl
+ engines[keyword] = { keyword, searchUrl, description }
+
+ callback engines
+
+ # Use the parsed search-engine configuration, possibly asynchronously.
+ use: (callback) ->
+ @searchEngines.use callback
+
+ # Both set (refresh) the search-engine configuration and use it at the same time.
+ refreshAndUse: (searchEngines, callback) ->
+ @refresh searchEngines
+ @use callback
+
# This creates a new function out of an existing function, where the new function takes fewer arguments. This
# allows us to pass around functions instead of functions + a partial list of arguments.
Function::curry = ->
@@ -179,6 +278,8 @@ Function::curry = ->
Array.copy = (array) -> Array.prototype.slice.call(array, 0)
String::startsWith = (str) -> @indexOf(str) == 0
+String::ltrim = -> @replace /^\s+/, ""
+String::rtrim = -> @replace /\s+$/, ""
globalRoot = window ? global
globalRoot.extend = (hash1, hash2) ->
@@ -186,5 +287,84 @@ globalRoot.extend = (hash1, hash2) ->
hash1[key] = hash2[key]
hash1
+# A simple cache. Entries used within two expiry periods are retained, otherwise they are discarded.
+# At most 2 * @entries entries are retained.
+class SimpleCache
+ # expiry: expiry time in milliseconds (default, one hour)
+ # entries: maximum number of entries in @cache (there may be up to this many entries in @previous, too)
+ constructor: (@expiry = 60 * 60 * 1000, @entries = 1000) ->
+ @cache = {}
+ @previous = {}
+ @lastRotation = new Date()
+
+ has: (key) ->
+ @rotate()
+ (key of @cache) or key of @previous
+
+ # Set value, and return that value. If value is null, then delete key.
+ set: (key, value = null) ->
+ @rotate()
+ delete @previous[key]
+ if value?
+ @cache[key] = value
+ else
+ delete @cache[key]
+ null
+
+ get: (key) ->
+ @rotate()
+ if key of @cache
+ @cache[key]
+ else if key of @previous
+ @cache[key] = @previous[key]
+ delete @previous[key]
+ @cache[key]
+ else
+ null
+
+ rotate: (force = false) ->
+ if force or @entries < Object.keys(@cache).length or @expiry < new Date() - @lastRotation
+ @lastRotation = new Date()
+ @previous = @cache
+ @cache = {}
+
+ clear: ->
+ @rotate true
+ @rotate true
+
+# This is a simple class for the common case where we want to use some data value which may be immediately
+# available, or for which we may have to wait. It implements a use-immediately-or-wait queue, and calls the
+# fetch function to fetch the data asynchronously.
+class AsyncDataFetcher
+ constructor: (fetch) ->
+ @data = null
+ @queue = []
+ Utils.nextTick =>
+ fetch (@data) =>
+ callback @data for callback in @queue
+ @queue = null
+
+ use: (callback) ->
+ if @data? then callback @data else @queue.push callback
+
+# This takes a list of jobs (functions) and runs them, asynchronously. Functions queued with @onReady() are
+# run once all of the jobs have completed.
+class JobRunner
+ constructor: (@jobs) ->
+ @fetcher = new AsyncDataFetcher (callback) =>
+ for job in @jobs
+ do (job) =>
+ Utils.nextTick =>
+ job =>
+ @jobs = @jobs.filter (j) -> j != job
+ callback true if @jobs.length == 0
+
+ onReady: (callback) ->
+ @fetcher.use callback
+
root = exports ? window
root.Utils = Utils
+root.SearchEngines = SearchEngines
+root.SimpleCache = SimpleCache
+root.AsyncDataFetcher = AsyncDataFetcher
+root.JobRunner = JobRunner