var completion = (function() { //============ Helper functions and objects ============// /** Singleton object that provides helpers and caching for fuzzy completion. */ var fuzzyMatcher = (function() { var self = {}; self.matcherCacheSize = 300; self.regexNonWord = /[\W_]/i; // cache generated regular expressions self.matcherCache = {}; // cache filtered results from recent queries self.filterCache = {}; /** Normalizes the string specified in :query. Strips any non-word characters and converts * to lower case. */ self.normalize = function(query) { return query.replace(self.regexNonWord, '').toLowerCase(); } /** Calculates a very simple similarity value between a :query and a :string. The current * implementation simply returns the cumulated length of query parts that are also found * in the string, raised to the power of 3. */ 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) { for (var i = query.length; i >= start; --i) { if (str.indexOf(query.slice(start, i)) >= 0) { sum += i - start; break; } } } return sum * sum * sum; } /** Trims the size of the regex cache to the configured size using a FIFO algorithm. */ self.cleanMatcherCache = function() { // remove old matchers queries = Object.keys(self.matcherCache); for (var i = 0; i < queries.length - self.matcherCacheSize; ++i) delete self.matcherCache(queries[i]); } /** 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.getMatcher = function(query) { query = self.normalize(query); if (!(query in self.matcherCache)) { // build up a regex for fuzzy matching var regex = '^'; for (var i = 0; i < query.length; ++i) regex += '([^' + query[i] + ']*)(' + query[i] + ')'; self.matcherCache[query] = new RegExp(regex + '(.*)$', 'i'); } return self.matcherCache[query]; } /** Clears the filter cache with the given ID. */ self.clearFilterCache = function(id) { if (id in self.filterCache) delete self.filterCache[id]; } /** Filters a list :ary 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 :ary. */ self.filter = function(query, ary, getValue, id, callback) { var filtered = []; var source = ary; if (!(id in self.filterCache)) self.filterCache[id] = {}; // find the most specific list of sources in the cache var maxSpecificity = 0; 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 cache entry the most specific so far? var specificity = self.filterCache[id][key].length; if (query.indexOf(key) == 0 && specificity > maxSpecificity) { source = self.filterCache[id][key]; maxSpecificity = specificity; } } // clean up self.cleanMatcherCache(); var matcher = self.getMatcher(query); for (var i = 0; i < source.length; ++i) { if (!matcher.test(getValue(source[i]))) continue; filtered.push(source[i]); callback(source[i]); } self.filterCache[id][query] = filtered; } return self; })(); /** Strips HTML tags using a naive regex replacement. Optinally, saves the stripped HTML tags in a * dictionary indexed by the position where the tag should be reinserted. */ function stripHtmlTags(str, positions) { var result = str.replace(/<[^>]*>/g, ''); if (!positions) return result; // we need to get information about where the tags can be reinserted after some string processing var start; var end = -1; var stripped = 0; while (0 <= (start = str.indexOf('<', end + 1))) { end = str.indexOf('>', start); positions[start - stripped] = str.slice(start, end + 1); stripped += end - start + 1; } return result; } /** Creates an action that opens :url in the current tab by default or in a new tab as an alternative. */ function createActionOpenUrl(url) { return [ function() { window.location = url; }, function() { window.open(url); }, ] } /** Creates a completion that renders by marking fuzzy-matched parts. */ function createHighlightingCompletion(query, str, action, relevancy) { return { action: action, relevancy: relevancy, render: function() { // 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 = {}; str = stripHtmlTags(str, htmlTags); var match = fuzzyMatcher.getMatcher(query).exec(str); var html = ''; var i = 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 (i in htmlTags) html += htmlTags[i]; html += str; ++i; } // iterate over the match groups. They are non-matched and matched string parts, in alternating order for (var m = 1; m < match.length; ++m) { if (m % 2 == 1) // 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 for (var j = 0; j < match[m].length; ++j) addToHtml(match[m][j]); else // we have a matched part, which consists of exactly one character. In addition to the character // itself, we add some decorating HTML. addToHtml('' + match[m] + ''); }; // call it another time so that a tag at the very last position is reinserted addToHtml(''); return html; }, } } /** Creates a function that returns a constant value */ function createConstantFunction(x) { return function() { return x; } } /** Helper class to construct fuzzy completers for asynchronous data sources like history or bookmark * matchers. */ var AsyncFuzzyUrlCompleter = function() { this.completions = null; this.id = utils.createUniqueId(); this.readyCallback = this.fallbackReadyCallback = function(results) { this.completions = results; } this.extractStringFromMatch = function(match) { return stripHtmlTags(match.str); } } AsyncFuzzyUrlCompleter.prototype = { // to be implemented by subclasses refresh: function() { }, calculateRelevancy: function(query, match) { return match.url.length * 10 / (fuzzyMatcher.calculateRelevancy(query, this.extractStringFromMatch(match)) + 1); }, createAction: function(match) { return createActionOpenUrl(match.url); }, filter: function(query, callback) { var self = this; var handler = function(results) { var filtered = []; fuzzyMatcher.filter(query, results, self.extractStringFromMatch, self.id, function(match) { filtered.push(createHighlightingCompletion( query, match.str, self.createAction(match), self.calculateRelevancy(query, match))); }); callback(filtered); } // 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.readyCallback = function(results) { handler(results); this.readyCallback = this.fallbackReadyCallback; this.readyCallback(results); } } }, } //========== Completer implementations ===========// /** A simple completer that suggests to open the input string as an URL or to trigger a web search for the * given term, depending on whether it thinks the input is an URL or not. */ var SmartCompleter = function(commands) { commands = commands || {}; var commandKeys = Object.keys(commands); this.refresh = function() { }; /** Checks if the input is a special command and if yes, add according suggestions to the given array */ this.addCommandSuggestions = function(query, suggestions) { // check if the input is a special command for (var i = 0; i < commandKeys.length; ++i) { var key = commandKeys[i]; if (query.indexOf(key) != 0) continue; var term = query.slice(key.length, query.length); var command = commands[key]; var desc = command[0]; var pattern = command[1]; var url; if (typeof pattern === 'function') url = pattern(term); else url = pattern.replace(/%s/g, term); suggestions.push({ render: createConstantFunction('' + desc + ' ' + term), action: createActionOpenUrl(utils.createFullUrl(url)), }); } } /** Checks if the input is a URL. If yes, add the URL to the list of suggestions. If no, add a search * query to the list of suggestions. */ this.addUrlOrSearchSuggestion = function(query, suggestions) { var url, str; // trim query query = query.replace(/^\s+|\s+$/g, ''); if (utils.isUrl(query)) { url = utils.createFullUrl(query); str = 'goto ' + query; } else { url = utils.createSearchUrl(query); str = 'search ' + query; } suggestions.push({ render: function() { return str; }, action: createActionOpenUrl(url), // relevancy will always be the lowest one, so the suggestion is at the top relevancy: -1, }); } this.filter = function(query, callback) { var suggestions = []; this.addCommandSuggestions(query, suggestions); this.addUrlOrSearchSuggestion(query, suggestions); callback(suggestions); }; } /** A fuzzy history completer */ var FuzzyHistoryCompleter = function(maxEntries) { AsyncFuzzyUrlCompleter.call(this); this.maxEntries = maxEntries || 1000; } FuzzyHistoryCompleter.prototype = new AsyncFuzzyUrlCompleter; FuzzyHistoryCompleter.prototype.refresh = function() { this.completions = null; // reset completions // asynchronously fetch history items var port = chrome.extension.connect({ name: "getHistory" }) ; var self = this; port.onMessage.addListener(function(msg) { var results = []; for (var i = 0; i < msg.history.length; ++i) { var historyItem = msg.history[i]; var title = ''; if (historyItem.title.length > 0) title = ' ' + historyItem.title + ''; results.push({ str: 'history ' + historyItem.url + title, url: historyItem.url, }); } port = null; self.readyCallback(results); }); port.postMessage({ maxResults: this.maxEntries }); } /** A fuzzy bookmark completer */ var FuzzyBookmarkCompleter = function() { AsyncFuzzyUrlCompleter.call(this); } FuzzyBookmarkCompleter.prototype = new AsyncFuzzyUrlCompleter; FuzzyBookmarkCompleter.prototype.refresh = function() { this.completions = null; // reset completions var port = chrome.extension.connect({ name: "getAllBookmarks" }) ; var self = this; port.onMessage.addListener(function(msg) { var results = []; for (var i = 0; i < msg.bookmarks.length; ++i) { var bookmark = msg.bookmarks[i]; if (bookmark.url === undefined) continue; var title = ''; if (bookmark.title.length > 0) title = ' ' + bookmark.title + ''; results.push({ str: 'bookmark ' + bookmark.url + title, url: bookmark.url, }); } port = null; self.readyCallback(results); }); port.postMessage(); } /** A fuzzy tab completer */ var FuzzyTabCompleter = function() { AsyncFuzzyUrlCompleter.call(this); } FuzzyTabCompleter.prototype = new AsyncFuzzyUrlCompleter; FuzzyTabCompleter.prototype.createAction = function(match) { var open = function() { chrome.extension.sendRequest({ handler: 'selectSpecificTab', id: match.tab.id }); } return [ open, open ]; } FuzzyTabCompleter.prototype.refresh = function() { this.completions = null; // reset completions var port = chrome.extension.connect({ name: 'getTabsInCurrentWindow' }) ; var self = this; port.onMessage.addListener(function(msg) { var results = []; for (var i = 0; i < msg.tabs.length; ++i) { var tab = msg.tabs[i]; var title = ''; if (tab.title.length > 0) title = ' ' + tab.title + ''; results.push({ str: 'tab ' + tab.url + title, url: tab.url, tab: tab, }); } port = null; self.readyCallback(results); }); port.postMessage(); } /** A meta-completer that delegates queries and merges and sorts the results of a collection of other * completer instances. */ var MergingCompleter = function(sources) { this.sources = sources; } MergingCompleter.prototype = { refresh: function() { for (var i = 0; i < this.sources.length; ++i) this.sources[i].refresh(); }, filter: function(query, callback) { var all = []; var counter = this.sources.length; for (var i = 0; i < this.sources.length; ++i) { this.sources[i].filter(query, function(results) { all = all.concat(results); if (--counter > 0) return; // all sources have provided results by now, so we can sort and return all.sort(function(a,b) { return a.relevancy - b.relevancy; }); callback(all); }); } } } // public interface return { FuzzyHistoryCompleter: FuzzyHistoryCompleter, FuzzyBookmarkCompleter: FuzzyBookmarkCompleter, FuzzyTabCompleter: FuzzyTabCompleter, SmartCompleter: SmartCompleter, MergingCompleter: MergingCompleter, }; })();