diff options
Diffstat (limited to 'background_scripts')
| -rw-r--r-- | background_scripts/commands.js | 252 | ||||
| -rw-r--r-- | background_scripts/completion.js | 626 | ||||
| -rw-r--r-- | background_scripts/settings.js | 81 |
3 files changed, 959 insertions, 0 deletions
diff --git a/background_scripts/commands.js b/background_scripts/commands.js new file mode 100644 index 00000000..83ef050f --- /dev/null +++ b/background_scripts/commands.js @@ -0,0 +1,252 @@ +var availableCommands = {}; +var keyToCommandRegistry = {}; + +/* + * Registers a command, making it available to be optionally bound to a key. + * options: + * - background: whether this command needs to be run against the background page. + * - passCountToFunction: true if this command should have any digits which were typed prior to the + * command passed to it. This is used to implement e.g. "closing of 3 tabs". + */ +function addCommand(command, description, options) { + if (availableCommands[command]) { + console.log(command, "is already defined! Check commands.js for duplicates."); + return; + } + + options = options || {}; + availableCommands[command] = { description: description, + isBackgroundCommand: options.background, + passCountToFunction: options.passCountToFunction + }; +} + +function mapKeyToCommand(key, command) { + if (!availableCommands[command]) { + console.log(command, "doesn't exist!"); + return; + } + + keyToCommandRegistry[key] = { command: command, + isBackgroundCommand: availableCommands[command].isBackgroundCommand, + passCountToFunction: availableCommands[command].passCountToFunction + }; +} + +function unmapKey(key) { delete keyToCommandRegistry[key]; } + +/* Lower-case the appropriate portions of named keys. + * + * A key name is one of three forms exemplified by <c-a> <left> or <c-f12> + * (prefixed normal key, named key, or prefixed named key). Internally, for + * simplicity, we would like prefixes and key names to be lowercase, though + * humans may prefer other forms <Left> or <C-a>. + * On the other hand, <c-a> and <c-A> are different named keys - for one of + * them you have to press "shift" as well. + */ +function normalizeKey(key) { + return key.replace(/<[acm]-/ig, function(match){ return match.toLowerCase(); }) + .replace(/<([acm]-)?([a-zA-Z0-9]{2,5})>/g, function(match, optionalPrefix, keyName){ + return "<" + ( optionalPrefix ? optionalPrefix : "") + keyName.toLowerCase() + ">"; + }); +} + +function parseCustomKeyMappings(customKeyMappings) { + lines = customKeyMappings.split("\n"); + + for (var i = 0; i < lines.length; i++) { + if (lines[i][0] == "\"" || lines[i][0] == "#") { continue } + split_line = lines[i].split(/\s+/); + + var lineCommand = split_line[0]; + + if (lineCommand == "map") { + if (split_line.length != 3) { continue; } + var key = normalizeKey(split_line[1]); + var vimiumCommand = split_line[2]; + + if (!availableCommands[vimiumCommand]) { continue } + + console.log("Mapping", key, "to", vimiumCommand); + mapKeyToCommand(key, vimiumCommand); + } + else if (lineCommand == "unmap") { + if (split_line.length != 2) { continue; } + + var key = normalizeKey(split_line[1]); + + console.log("Unmapping", key); + unmapKey(key); + } + else if (lineCommand == "unmapAll") { + keyToCommandRegistry = {}; + } + } +} + +function clearKeyMappingsAndSetDefaults() { + keyToCommandRegistry = {}; + + var defaultKeyMappings = { + "?": "showHelp", + "j": "scrollDown", + "k": "scrollUp", + "h": "scrollLeft", + "l": "scrollRight", + "gg": "scrollToTop", + "G": "scrollToBottom", + "zH": "scrollToLeft", + "zL": "scrollToRight", + "<c-e>": "scrollDown", + "<c-y>": "scrollUp", + + "d": "scrollPageDown", + "u": "scrollPageUp", + "r": "reload", + "gs": "toggleViewSource", + + "i": "enterInsertMode", + + "H": "goBack", + "L": "goForward", + "gu": "goUp", + + "gi": "focusInput", + + "f": "linkHints.activateMode", + "F": "linkHints.activateModeToOpenInNewTab", + "<a-f>": "linkHints.activateModeWithQueue", + + "/": "enterFindMode", + "n": "performFind", + "N": "performBackwardsFind", + + "[[": "goPrevious", + "]]": "goNext", + + "yy": "copyCurrentUrl", + "yf": "linkHints.activateModeToCopyLinkUrl", + + "p": "openCopiedUrlInCurrentTab", + "P": "openCopiedUrlInNewTab", + + "K": "nextTab", + "J": "previousTab", + "gt": "nextTab", + "gT": "previousTab", + "g0": "firstTab", + "g$": "lastTab", + + "t": "createTab", + "x": "removeTab", + "X": "restoreTab", + + "o": "vomnibar.activate", + "O": "vomnibar.activateWithCurrentUrl", + + "T": "vomnibar.activateTabSelection", + + "gf": "nextFrame", + }; + + for (var key in defaultKeyMappings) + mapKeyToCommand(key, defaultKeyMappings[key]); +} + +// This is a mapping of: commandIdentifier => [description, options]. +var commandDescriptions = { + // Navigating the current page + showHelp: ["Show help", { background: true }], + scrollDown: ["Scroll down"], + 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"], + 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"], + + copyCurrentUrl: ["Copy the current URL to the clipboard"], + 'linkHints.activateModeToCopyLinkUrl': ["Copy a link URL to the clipboard"], + openCopiedUrlInCurrentTab: ["Open the clipboard's URL in the current tab", { background: true }], + openCopiedUrlInNewTab: ["Open the clipboard's URL in a new tab", { background: true }], + + enterInsertMode: ["Enter insert mode"], + + focusInput: ["Focus the first (or n-th) text box on the page", { passCountToFunction: true }], + + 'linkHints.activateMode': ["Open a link in the current tab"], + 'linkHints.activateWithCurrentUrl': ["Open a link in a new tab"], + 'linkHints.activateModeWithQueue': ["Open multiple links in a new tab"], + + enterFindMode: ["Enter find mode"], + 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 >"], + + // Navigating your history + goBack: ["Go back in history", { passCountToFunction: true }], + goForward: ["Go forward in history", { passCountToFunction: true }], + + // Navigating the URL hierarchy + goUp: ["Go up the URL hierarchy", { passCountToFunction: true }], + + // Manipulating tabs + nextTab: ["Go one tab right", { background: true }], + 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 }], + removeTab: ["Close current tab", { background: true }], + restoreTab: ["Restore closed tab", { background: true }], + + "vomnibar.activate": ["Open URL, bookmark, or history entry"], + "vomnibar.activateWithCurrentUrl": ["Open URL, bookmark, history entry, starting with the current URL"], + "vomnibar.activateTabSelection": ["Search through your open tabs"], + + nextFrame: ["Cycle forward to the next frame on the page", { background: true, passCountToFunction: true }] +}; + +for (var command in commandDescriptions) + addCommand(command, commandDescriptions[command][0], commandDescriptions[command][1]); + + +// An ordered listing of all available commands, grouped by type. This is the order they will +// be shown in the help page. +var commandGroups = { + pageNavigation: + ["scrollDown", "scrollUp", "scrollLeft", "scrollRight", + "scrollToTop", "scrollToBottom", "scrollToLeft", "scrollToRight", "scrollPageDown", + "scrollPageUp", "scrollFullPageUp", "scrollFullPageDown", + "reload", "toggleViewSource", "copyCurrentUrl", "linkHints.activateModeToCopyLinkUrl", + "openCopiedUrlInCurrentTab", "openCopiedUrlInNewTab", "goUp", + "enterInsertMode", "focusInput", + "linkHints.activateMode", "linkHints.activateModeToOpenInNewTab", "linkHints.activateModeWithQueue", + "vomnibar.activate", "vomnibar.activateWithCurrentUrl", "vomnibar.activateTabSelection", + "goPrevious", "goNext", "nextFrame"], + findCommands: ["enterFindMode", "performFind", "performBackwardsFind"], + historyNavigation: + ["goBack", "goForward"], + tabManipulation: + ["nextTab", "previousTab", "firstTab", "lastTab", "createTab", "removeTab", "restoreTab"], + misc: + ["showHelp"] +}; + +// Rarely used commands are not shown by default in the help dialog or in the README. The goal is to present +// 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. +var advancedCommands = [ + "scrollToLeft", "scrollToRight", + "goUp", "focusInput", "linkHints.activateModeWithQueue", + "goPrevious", "goNext"]; diff --git a/background_scripts/completion.js b/background_scripts/completion.js new file mode 100644 index 00000000..b31c4918 --- /dev/null +++ b/background_scripts/completion.js @@ -0,0 +1,626 @@ +/* + * This contains the definition of the completers used for the Vomnibox's suggestion UI. A complter will take + * a query (whatever the user typed into the Vomnibox) and return a list of matches, e.g. bookmarks, domains, + * URLs from history. + * + * 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: + * - refresh(): refreshes the completer's data source (e.g. refetches the list of bookmarks from Chrome). + * - filter(query, callback): "query" will be whatever the user typed into the Vomnibox. "callback" is a + * function which will be invoked with a list of LazyCompletionResults as its first argument. + * + * A completer's filter() function returns a list of LazyCompletionResults. This contains a relevancy score + * for the result, as well as a function to build the full result (e.g. the HTML representing this result). + * + * The MultiCompleter collects a big list of LazyCompletionResults from many completers by calling each of + * their filter functions in turn, sorts the results by relevancy, and then calls build() on the top N + * results. This allows us to avoid generating HTML for all of the results we're not going to use. + * The objects returned from build() are sent to the Vomnibox frontend script to be shown in the UI. + */ +var completion = (function() { + + /* + * An object which contains a relevancy score for the given completion, and a function which can be + * invoked to build its HTML. + * Calling build() should return an object of the form: + * { html: "", action: { functionName: "", args: [] } } + * This object is eventually sent back to the Vomnibox frontend script. "action" contains the action to + * be performed by the frontend script if this result is chosen (user selects it and hits enter). + * "action" includes the function the frontendScript should execute (e.g. "navigateToUrl") along with any + * arguments (like the URL). + * "html" is the HTML representation of this result, with some characters emphasized to higlight the query. + * + * This is called "lazy" because it takes in a function to lazily compute a result's html. That operation + * can be kind of expensive, so you only want to do it to the top completion results, after you've sorted + * them by relevancy. + */ + var LazyCompletionResult = function(relevancy, buildFunction) { + this.relevancy = relevancy; + this.build = buildFunction; + } + + /* + * A completer which takes in a list of keyword commands (like "wiki" for "search wikipedia") and will + * decide if your query is a command, a URL that you want to visit, or a search term. + */ + var SmartKeywordCompleter = Class.extend({ + /* + * - commands: a list of commands of the form: { keyword: [title, url] }, e.g.: + * { "wiki ": ["Wikipedia (en)", "http://en.wikipedia.org/wiki/%s" ] + */ + init: function(commands) { + this.commands = commands || {}; + this.commandKeys = Object.keys(commands); + }, + + refresh: function() { }, + + /** Returns the suggestions matching the user-defined commands */ + getCommandSuggestions: function(query, suggestions) { + return this.commandKeys.filter(function(cmd) { return query.indexOf(cmd) == 0 }).map(function(cmd) { + var term = query.slice(cmd.length); + var desc = this.commands[cmd][0]; + var pattern = this.commands[cmd][1]; + var url = (typeof pattern == "function") ? pattern(term) : pattern.replace(/%s/g, term); + + // this will appear even before the URL/search suggestion + return new LazyCompletionResult(-2, function() { + return { + html: createCompletionHtml(desc, term), + action: { functionName: "navigateToUrl", args: [utils.createFullUrl(url)] }, + }}) + }.proxy(this)); + }, + + /** Checks if the input is a URL. If yes, returns a suggestion to visit it. If no, returns a suggestion + * to start a web search. */ + getUrlOrSearchSuggestion: function(query, suggestions) { + // trim query + query = query.replace(/^\s+|\s+$/g, ''); + var isUrl = utils.isUrl(query); + return new LazyCompletionResult(-1, function() { + return { + html: createCompletionHtml(isUrl ? "goto" : "search", query), + action: { functionName: "navigateToUrl", + args: isUrl ? [utils.createFullUrl(query)] : [utils.createSearchUrl(query)] } + }}); + }, + + filter: function(query, callback) { + suggestions = this.getCommandSuggestions(query); + suggestions.push(this.getUrlOrSearchSuggestion(query)); + callback(suggestions); + } + }); + + /* + * A generic asynchronous completer which is used by completers which have asynchronous data sources, + * like history or bookmarks. + */ + var AsyncCompleter = Class.extend({ + init: function() { + this.id = utils.createUniqueId(); + this.reset(); + this.resultsReady = this.fallbackReadyCallback = function(results) { this.completions = results; } + }, + + reset: function() { + fuzzyMatcher.invalidateFilterCache(this.id); + this.completions = null; + }, + + /* + * This creates an intermediate representation of a completion which will later be called with a specific + * query. + * - type: the type of item we're completing against, e.g. "bookmark", "history", "tab". + * - item: the item itself. This should include a url and title property (bookmark, history and tab + * objects include both of these). + * - action: the action to take in the Vomnibox frontend + * + * It's used to save us work -- we call this on every bookmark in your bookmarks list when we first fetch + * them, for instance, and we don't want to do some the same work again every time a new query is + * processed. + * + * TODO(philc): It would be nice if this could be removed; it's confusing. + * */ + createUnrankedCompletion: function(type, item, action) { + var url = item.url; + var parts = [type, url, item.title]; + var str = parts.join(" "); + action = action || { functionName: "navigateToUrl", args: [url] }; + + function createLazyCompletion(query) { + var relevancy = url.length / fuzzyMatcher.calculateRelevancy(query, str) + return new LazyCompletionResult(relevancy, function() { + return { + html: renderFuzzy(query, createCompletionHtml.apply(null, parts)), + action: action, + }}); + } + + // add one more layer of indirection: For filtering, we only need the string to match. + // Only after we reduced the number of possible results, we call :bind on them to get + // an actual completion object + return { + completionString: parts.join(" "), + createLazyCompletion: createLazyCompletion, + } + }, + + processResults: function(query, results) { + results = fuzzyMatcher.filter(query, results, + function(match) { return match.completionString }, this.id); + return results.map(function(result) { return result.createLazyCompletion(query); }); + }, + + filter: function(query, callback) { + var handler = function(results) { callback(this.processResults(query, results)); }.proxy(this); + + // are the results ready? + if (this.completions !== null) { + // yes: call the callback synchronously + handler(this.completions); + } else { + // no: register the handler as a callback + this.resultsReady = function(results) { + handler(results); + this.resultsReady = this.fallbackReadyCallback; + this.resultsReady(results); + }.proxy(this); + } + } + }); + + var FuzzyBookmarkCompleter = Class.extend({ + init: function() { this.asyncCompleter = new AsyncCompleter(); }, + filter: function(query, callback) { return this.asyncCompleter.filter(query, callback); }, + + // Traverses the bookmark hierarhcy and retuns a list of all bookmarks in the tree. + traverseBookmarkTree: function(bookmarks) { + var results = []; + var toVisit = bookmarks; + while (toVisit.length > 0) { + var bookmark = toVisit.shift(); + results.push(bookmark); + if (bookmark.children) + toVisit.push.apply(toVisit, bookmark.children); + } + return results; + }, + + refresh: function() { + this.asyncCompleter.reset(); + chrome.bookmarks.getTree(function(bookmarks) { + var results = this.traverseBookmarkTree(bookmarks); + var validResults = results.filter(function(b) { return b.url !== undefined; }); + var matches = validResults.map(function(bookmark) { + return this.asyncCompleter.createUnrankedCompletion("bookmark", bookmark); + }.proxy(this)); + this.asyncCompleter.resultsReady(matches); + }.proxy(this)); + } + }); + + var FuzzyHistoryCompleter = Class.extend({ + init: function(maxResults) { + this.asyncCompleter = new AsyncCompleter(); + this.maxResults = maxResults; + }, + + filter: function(query, callback) { return this.asyncCompleter.filter(query, callback); }, + + refresh: function() { + this.asyncCompleter.reset(); + + historyCache.use(function(history) { + this.asyncCompleter.resultsReady(history.slice(-this.maxResults).map(function(item) { + return this.asyncCompleter.createUnrankedCompletion("history", item); + }.proxy(this))); + }.proxy(this)); + } + }); + + var FuzzyTabCompleter = Class.extend({ + init: function() { this.asyncCompleter = new AsyncCompleter(); }, + + filter: function(query, callback) { return this.asyncCompleter.filter(query, callback); }, + + refresh: function() { + this.asyncCompleter.reset(); + chrome.tabs.getAllInWindow(null, function(tabs) { + this.asyncCompleter.resultsReady(tabs.map(function(tab) { + return this.asyncCompleter.createUnrankedCompletion("tab", tab, + { functionName: "switchToTab", args: [tab.id] }); + }.proxy(this))); + }.proxy(this)); + } + }); + + /* + * A completer which matches only domains from sites in your history with the current query. + */ + var DomainCompleter = Class.extend({ + // A mapping of doamin => useHttps, where useHttps is a boolean. + domains: null, + + withDomains: function(callback) { + var self = this; + function buildResult() { + return Object.keys(self.domains).map(function(dom) { + return [dom, self.domains[dom]]; + }); + } + if (self.domains !== null) + return callback(buildResult()); + + self.domains = {}; + + function processDomain(domain, https) { + // non-www version is preferrable, so check if we have it already + if (domain.indexOf('www.') == 0 && self.domains.hasOwnProperty(domain.slice(4))) + domain = domain.slice(4); + + // HTTPS is preferrable + https = https || self.domains[domain] || self.domains['www.' + domain]; + + self.domains[domain] = !!https; + delete self.domains['www.' + domain]; + } + + function processUrl(url) { + parts = url.split('/'); + processDomain(parts[2], parts[0] == 'https:'); + } + + historyCache.use(function(history) { + history.forEach(function(item) { processUrl(item.url); }); + }); + + chrome.history.onVisited.addListener(function(item) { processUrl(item.url); }); + + callback(buildResult()); + }, + + refresh: function() { }, + + filter: function(query, callback) { + var best = null; + this.withDomains(function(domains) { + var bestOffset = 1000; + domains.forEach(function(result) { + var domain = result[0]; + var protocol = result[1] ? 'https' : 'http'; + + var offset = domain.indexOf(query); + if (offset < 0 || offset >= bestOffset) + return; + + // found a new optimum + bestOffset = offset; + best = new LazyCompletionResult(-1.5, function() { + return { + html: createCompletionHtml("site", domain), + action: { functionName: "navigateToUrl", args: [protocol + "://" + domain] }, + }}); + }); + }); + callback(best ? [best] : []); + } + }); + + /** A meta-completer that delegates queries and merges and sorts the results of a collection of other + * completer instances given in :sources. The optional argument :queryThreshold determines how long a + * query has to be to trigger a search. */ + var MultiCompleter = Class.extend({ + init: function(sources, queryThreshold) { + if (queryThreshold === undefined) + queryThreshold = 1; // default + this.sources = sources; + this.queryThreshold = queryThreshold; + }, + + refresh: function() { this.sources.forEach(function(source) { source.refresh(); }); }, + + filter: function(query, maxResults, callback) { + if (query.length < this.queryThreshold) { + callback([]); + return; + } + + var allResults = []; + var counter = this.sources.length; + + this.sources.forEach(function(source) { + source.filter(query, function(results) { + allResults = allResults.concat(results); + if (--counter > 0) + return; + + // all sources have provided results by now, so we can sort and return + allResults.sort(function(a,b) { return a.relevancy - b.relevancy; }); + // evalulate lazy completions for the top n results + callback(allResults.slice(0, maxResults).map(function(result) { return result.build(); })); + }); + }); + } + }); + + /** Singleton object that provides helpers and caching for fuzzy completion. */ + var fuzzyMatcher = (function() { + var self = {}; + + self.timeToClean = 0; + self.cacheSize = 1000; + self.regexNonWord = /[\W_]/ig; + + // cache generated regular expressions + self.regexpCache = {}; + // cache filtered results from recent queries + self.filterCache = {}; + self.normalizationCache = {}; + + /** Normalizes the string specified in :query. Strips any non-word characters and converts + * to lower case. */ + self.normalize = function(query) { + if (!(query in self.normalizationCache)) + self.normalizationCache[query] = query.replace(self.regexNonWord, '').toLowerCase(); + return self.normalizationCache[query]; + } + + /** Returns the non-matching and matching string parts, in alternating order (starting with a + * non-matching part) or null, if the string doesn't match the query. + * + * Sample: match("codecodec","code.google.com/codec") would yield ["", "code", ".google.com/", "codec"] + * + * Note that this function matches the longest possible parts of a string and is therefore not very + * efficient. There it falls back to a more performant, but less accurate regex matching if the + * normalized query is longer than 10 characters. + * + * _Don't use this to check if a string matches a query_. Use `getRegexp(query).test(str)` instead. + */ + self.getMatchGroups = function(query, str) { + query = self.normalize(query); + if (query.length == 0) + return str.length ? [str] : []; + if (query.length > 15) { + // for long query strings, the method is much too inefficient, so fall + // back to the less accurate regex matching + return self.getRegexp(query).exec(str).slice(1); + } + + for (var i = query.length; i >= 1; --i) { + var part = query.slice(0, i); + var partOffset = str.toLowerCase().indexOf(part); + if (partOffset < 0) + continue; + + // we use recursive backtracking here, this is why it's slow. + rest = self.getMatchGroups(query.slice(i), str.slice(partOffset + i)); + if (!rest) continue; + + return [ + str.slice(0, partOffset), + part, + ].concat(rest); + } + return null; + } + + /** Calculates a very simple similarity value between a :query and a :string */ + self.calculateRelevancy = function(query, str) { + query = self.normalize(query); + str = self.normalize(str); + var sum = 0; + + // only iterate over slices of the query starting at an offset up to 10 to save resources + for (var start = 0; start < 20 && start < query.length; ++start) { + for (var i = query.length; i >= start; --i) { + if (str.indexOf(query.slice(start, i)) >= 0) { + var length = i - start; + sum += length * length; + break; + } + } + } + return sum * sum * sum; + } + + /** Trims the size of the caches to the configured size using a FIFO algorithm. */ + self.cleanCache = function() { + // remove old cached regexes + Object.keys(self.regexpCache).slice(self.cacheSize).forEach(function(query) { + delete self.regexpCache[query]; + }); + // remove old cached normalization results + Object.keys(self.normalizationCache).slice(self.cacheSize).forEach(function(query) { + delete self.normalizationCache[query]; + }); + } + + /** Returns a regex that matches a string using a fuzzy :query. Example: The :query "abc" would result + * in a regex like /^([^a])*(a)([^b])*(b)([^c])*(c)(.*)$/ + */ + self.getRegexp = function(query) { + query = self.normalize(query); + if (!(query in self.regexpCache)) { + // build up a regex for fuzzy matching. This is the fastest method I checked (faster than: + // string building, splice, concat, multi-level join) + var regex = ['^']; + for (var i = 0; i < query.length; ++i) { + regex.push('([^'); + regex.push(query[i]); + regex.push(']*)('); + regex.push(query[i]); + regex.push(')'); + } + regex.push('(.*)$'); + self.regexpCache[query] = new RegExp(regex.join(''), 'i'); + } + return self.regexpCache[query]; + } + + /** Clear the cache for the given source, e.g. for refreshing */ + self.invalidateFilterCache = function(id) { + self.filterCache[id] = {}; + } + + /** Filters a collection :source using fuzzy matching against an input string :query. If a query with + * a less specific query was issued before (e.g. if the user added a letter to the query), the cached + * results of the last filtering are used as a starting point, instead of :source. + */ + self.filter = function(query, source, getValue, id) { + if (!(id in self.filterCache)) + self.filterCache[id] = {}; + + // find the most narrow list of results in the cache + var optSpecificity = source.length; + var specificity; + for (key in self.filterCache[id]) { + if (!self.filterCache[id].hasOwnProperty(key)) + continue; + + if ((query.indexOf(key) != 0 && key.indexOf(query) != 0) || key.length > query.length) { + // cache entry no longer needed + delete self.filterCache[id][key]; + continue; + } + + // is this a plausible result set to use as a source? + if (query.indexOf(key) < 0) + continue; + + // is this cache entry the most specific so far? + specificity = self.filterCache[id][key].length; + if (specificity < optSpecificity) { + source = self.filterCache[id][key]; + optSpecificity = specificity; + } + } + + // don't clean up the caches every iteration + if (++self.timeToClean > 100) { + self.timeToClean = 0; + self.cleanCache(); + } + + var regexp = self.getRegexp(query); + var filtered = source.filter(function(x) { return regexp.test(getValue(x)) }); + self.filterCache[id][query] = filtered; + return filtered; + } + + return self; + })(); + + var htmlRegex = /<[^>]*>|&[a-z]+;/gi; + + /** Strips HTML tags and entities using a naive regex replacement. Optionally, saves the stripped + * HTML tags in a dictionary indexed by the position where the tag should be reinserted. */ + function stripHtmlTags(str, positions) { + if (!positions) + return str.replace(htmlRegex, ''); + + var match = str.match(htmlRegex); + if (!match) return; + match.reverse(); + var split = str.split(htmlRegex); + var offset = 0; + var i = 0; + split.forEach(function(text) { + if (match.length > 0) + positions[offset += text.length] = match.pop(); + }); + + return split.join(''); + } + + /** Creates an file-internal representation of a URL match with the given paramters */ + function createCompletionHtml(type, str, title) { + title = title || ''; + // sanitize input, it could come from a malicious web site + title = title.length > 0 ? ' <span class="title">' + utils.escapeHtml(title) + '</span>' : ''; + return '<em>' + type + '</em> ' + utils.escapeHtml(str) + title; + } + + /** Renders a completion by marking fuzzy-matched parts. */ + function renderFuzzy(query, html) { + // we want to match the content in HTML tags, but not the HTML tags themselves, so we remove the + // tags and reinsert them after the matching process + var htmlTags = {}; + var groups = fuzzyMatcher.getMatchGroups(query, stripHtmlTags(html, htmlTags)); + + html = []; + var htmlOffset = 0; + + // this helper function adds the HTML generated _for one single character_ to the HTML output + // and reinserts HTML tags stripped before, if they were at this position + function addToHtml(str) { + if (htmlOffset in htmlTags) + html.push(htmlTags[htmlOffset]); + html.push(str); + ++htmlOffset; + } + + function addCharsWithDecoration(str, before, after) { + before = before || ''; + after = after || ''; + for (var i = 0; i < str.length; ++i) + addToHtml(before + str[i] + after); + } + + // iterate over the match groups. They are non-matched and matched string parts, in alternating order + for (var i = 0; i < groups.length; ++i) { + if (i % 2 == 0) + // we have a non-matched part, it could have several characters. We need to insert them character + // by character, so that addToHtml can keep track of the position in the original string + addCharsWithDecoration(groups[i]); + else + // we have a matched part. In addition to the characters themselves, we add some decorating HTML. + addCharsWithDecoration(groups[i], '<span class="fuzzyMatch">', '</span>'); + }; + + // call it another time so that a tag at the very last position is reinserted + addToHtml(''); + return html.join(''); + } + + /** Singleton object that provides fast access to the Chrome history */ + var historyCache = (function() { + var size = 20000; + var cachedHistory = null; + + function use(callback) { + if (cachedHistory !== null) + return callback(cachedHistory); + + chrome.history.search({ text: '', maxResults: size, startTime: 0 }, function(history) { + // sorting in ascending order, so we can push new items to the end later + history.sort(function(a, b) { + return (a.lastVisitTime|| 0) - (b.lastVisitTime || 0); + }); + cachedHistory = history; + callback(history); + }); + + chrome.history.onVisited.addListener(function(item) { + // only cache newly visited sites + if (item.visitCount === 1) + cachedHistory.push(item); + }); + } + + return { use: use }; + })(); + + // public interface + return { + FuzzyBookmarkCompleter: FuzzyBookmarkCompleter, + FuzzyHistoryCompleter: FuzzyHistoryCompleter, + FuzzyTabCompleter: FuzzyTabCompleter, + SmartKeywordCompleter: SmartKeywordCompleter, + DomainCompleter: DomainCompleter, + MultiCompleter: MultiCompleter + }; +})() diff --git a/background_scripts/settings.js b/background_scripts/settings.js new file mode 100644 index 00000000..a00317b0 --- /dev/null +++ b/background_scripts/settings.js @@ -0,0 +1,81 @@ +/* + * Used by everyone to manipulate localStorage. + */ +var settings = { + + defaults: { + scrollStepSize: 60, + linkHintCharacters: "sadfjklewcmpgh", + filterLinkHints: false, + hideHud: false, + userDefinedLinkHintCss: + "div > .vimiumHintMarker {" + "\n" + + "/* linkhint boxes */ " + "\n" + + "background-color: yellow;" + "\n" + + "border: 1px solid #E3BE23;" + "\n" + + "}" + "\n\n" + + "div > .vimiumHintMarker span {" + "\n" + + "/* linkhint text */ " + "\n" + + "color: black;" + "\n" + + "font-weight: bold;" + "\n" + + "font-size: 12px;" + "\n" + + "}" + "\n\n" + + "div > .vimiumHintMarker > .matchingCharacter {" + "\n" + + "}", + excludedUrls: "http*://mail.google.com/*\n" + + "http*://www.google.com/reader/*\n", + + // 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,>>", + }, + + init: function() { + // settingsVersion was introduced in v1.31, and is used to coordinate data migration. We do not use + // previousVersion as it is used to coordinate the display of the upgrade message, and is not updated + // early enough when the extension loads. + // 1.31 was also the version where we converted all localStorage values to JSON. + if (!this.has("settingsVersion")) { + for (var key in localStorage) { + // filterLinkHints' checkbox state used to be stored as a string + if (key == "filterLinkHints") + localStorage[key] = localStorage[key] === "true" ? true : false; + else + localStorage[key] = JSON.stringify(localStorage[key]); + } + this.set("settingsVersion", utils.getCurrentVersion()); + } + }, + + get: function(key) { + if (!(key in localStorage)) + return this.defaults[key]; + else + return JSON.parse(localStorage[key]); + }, + + set: function(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 === this.defaults[key]) + this.clear(key); + else + localStorage[key] = JSON.stringify(value); + }, + + clear: function(key) { + delete localStorage[key]; + }, + + has: function(key) { + return key in localStorage; + }, + +}; + +settings.init(); |
