diff options
| author | Stephen Blott | 2015-05-10 05:35:50 +0100 |
|---|---|---|
| committer | Stephen Blott | 2015-05-10 05:39:15 +0100 |
| commit | 313a1f96d666f23c2bc75ef340f0f828319e127c (patch) | |
| tree | 8061232ac2d1affd10627f220381572dd6a1b001 | |
| parent | 275a91f203086b8f81542c174e736748dce68628 (diff) | |
| download | vimium-313a1f96d666f23c2bc75ef340f0f828319e127c.tar.bz2 | |
Search completion; refactor searchEngineCompleter.
This revamps how search-engine configuration is handled, and revises
some rather strange legacy code.
| -rw-r--r-- | background_scripts/completion.coffee | 254 | ||||
| -rw-r--r-- | background_scripts/settings.coffee | 3 | ||||
| -rw-r--r-- | lib/utils.coffee | 16 | ||||
| -rw-r--r-- | tests/unit_tests/completion_test.coffee | 21 | ||||
| -rw-r--r-- | tests/unit_tests/settings_test.coffee | 9 |
5 files changed, 139 insertions, 164 deletions
diff --git a/background_scripts/completion.coffee b/background_scripts/completion.coffee index cc334d78..94109b84 100644 --- a/background_scripts/completion.coffee +++ b/background_scripts/completion.coffee @@ -355,143 +355,135 @@ class TabCompleter tabRecency.recencyScore(suggestion.tabId) class SearchEngineCompleter - searchEngines: {} - - cancel: -> - CompletionEngines.cancel() + searchEngineConfig: null refresh: (port) -> - @searchEngines = SearchEngineCompleter.getSearchEngines() - # Let the vomnibar know the custom search engine keywords. - port.postMessage - handler: "customSearchEngineKeywords" - keywords: key for own key of @searchEngines + # Load and parse the search-engine configuration. + @searchEngineConfig = new AsyncDataFetcher (callback) -> + engines = {} + for line in Settings.get("searchEngines").split "\n" + line = line.trim() + continue if /^[#"]/.test line + tokens = line.split /\s+/ + continue unless 2 <= tokens.length + keyword = tokens[0].split(":")[0] + description = tokens[2..].join(" ") || "search (#{keyword})" + engines[keyword] = + keyword: keyword + searchUrl: tokens[1] + description: description + + # Deliver the resulting engines lookup table. + callback engines + + # Let the vomnibar know the custom search engine keywords. + port.postMessage + handler: "customSearchEngineKeywords" + keywords: key for own key of engines filter: ({ queryTerms, query }, onComplete) -> return onComplete [] if queryTerms.length == 0 - suggestions = [] - - { keyword, searchUrl, description } = @getSearchEngineMatches queryTerms, query - custom = searchUrl? and keyword? - searchUrl ?= Settings.get "searchUrl" - haveDescription = description? and 0 < description.length - description ||= "search#{if custom then " [#{keyword}]" else ""}" - - queryTerms = queryTerms[1..] if custom - query = queryTerms.join " " - - haveCompletionEngine = CompletionEngines.haveCompletionEngine searchUrl - # If this is a custom search engine and we have a completer, then exclude results from other completers. - # The truthiness of filter? also serves to distinguish two very different vomnibar behaviors, one for - # custom search engines with completers (filter? is true), and one for those without (filter? is false). - filter = - if custom and haveCompletionEngine - (suggestion) -> suggestion.type == description - else - null - # For custom search engines, we add an auto-selected suggestion. - if custom - # Note. This suggestion always appears at the top of the suggestion list. Its settings serves to serve - # to configure various vomnibar behaviors. - suggestions.push new Suggestion - queryTerms: queryTerms - type: description - url: Utils.createSearchUrl queryTerms, searchUrl - title: query - relevancy: 1 - insertText: if filter? then query else null - # For all custom search engines, we suppress the leading keyword, for example "w something" becomes - # "something" in the vomnibar. - suppressLeadingKeyword: true - # We complete suggestions only for custom search engines where we have an associated completion - # engine. - completeSuggestions: filter? - # We only use autoSelect and highlight query terms (on custom search engines) when we do not have a - # completer. - autoSelect: not filter? - forceAutoSelect: not filter? - highlightTerms: not filter? - - # Post suggestions and bail if there is no prospect of adding further suggestions. - if queryTerms.length == 0 or not haveCompletionEngine - return onComplete suggestions, { filter } - - onComplete suggestions, - filter: filter - continuation: (existingSuggestions, onComplete) => - suggestions = [] - # For custom search-engine queries, this adds suggestions only if we have a completer. For other queries, - # this adds suggestions for the default search engine (if we have a completer for that). - - # Relevancy: - # - Relevancy does not depend upon the actual suggestion (so, it does not depend upon word - # relevancy). We assume that the completion engine has already factored that in. Also, completion - # engines often handle spelling mistakes, in which case we wouldn't find the query terms in the - # suggestion anyway. - # - The relavancy is higher if the query term is longer. The idea is that search suggestions are more - # likely to be relevant if, after typing some number of characters, the user hasn't yet found - # a useful suggestion from another completer. - # - Scores are weighted such that they retain the order provided by the completion engine. - characterCount = query.length - queryTerms.length + 1 - relavancy = 0.6 * (Math.min(characterCount, 10.0)/10.0) - - if 0 < existingSuggestions.length - existingSuggestionsMinScore = existingSuggestions[existingSuggestions.length-1].relevancy - if relavancy < existingSuggestionsMinScore and MultiCompleter.maxResults <= existingSuggestions.length - # No suggestion we propose will have a high enough relavancy to beat the existing suggestions, so bail - # immediately. - return onComplete [] - - CompletionEngines.complete searchUrl, queryTerms, (completionSuggestions = []) => - for suggestion in completionSuggestions - suggestions.push new Suggestion - queryTerms: queryTerms - type: description - url: Utils.createSearchUrl suggestion, searchUrl - title: suggestion - relevancy: relavancy *= 0.9 - highlightTerms: false - insertText: suggestion - - # We keep at least three suggestions (if possible) and at most six. We keep more than three only if - # there are enough slots. The idea is that these suggestions shouldn't wholly displace suggestions - # from other completers. That would potentially be a problem because there is no relationship - # between the relevancy scores produced here and those produced by other completers. - count = Math.min 6, Math.max 3, MultiCompleter.maxResults - existingSuggestions.length - onComplete suggestions[...count] - - getSearchEngineMatches: (queryTerms, query = queryTerms.join " ") -> - # To allow users to write queries with leading search-engine keywords, leading whitespace disables custom - # search engines; for example, " w" is a regular query. - return {} if /^\s/.test query - # Trailing whitespace is significant when activating a custom search engine; for example, "w" (just a - # regular query) is different from "w " (a custom search engine). - length = queryTerms.length + (if /\s$/.test query then 1 else 0) - (1 < length and @searchEngines[queryTerms[0]]) or {} - - # Static data and methods for parsing the configured search engines. We keep a cache of the search-engine - # mapping in @searchEnginesMap. - @searchEnginesMap: null - - # Parse the custom search engines setting and cache it in SearchEngineCompleter.searchEnginesMap. - @parseSearchEngines: (searchEnginesText) -> - searchEnginesMap = SearchEngineCompleter.searchEnginesMap = {} - for line in searchEnginesText.split /\n/ - tokens = line.trim().split /\s+/ - continue if tokens.length < 2 or tokens[0].startsWith('"') or tokens[0].startsWith("#") - keywords = tokens[0].split ":" - continue unless keywords.length == 2 and not keywords[1] # So, like: [ "w", "" ]. - searchEnginesMap[keywords[0]] = - keyword: keywords[0] - searchUrl: tokens[1] - description: tokens[2..].join(" ") - - # Fetch the search-engine map, building it if necessary. - @getSearchEngines: -> - unless SearchEngineCompleter.searchEnginesMap? - SearchEngineCompleter.parseSearchEngines Settings.get "searchEngines" - SearchEngineCompleter.searchEnginesMap + @searchEngineConfig.use (engines) => + keyword = queryTerms[0] + + { custom, searchUrl, description, queryTerms } = + if engines[keyword]? and (1 < queryTerms.length or /\s$/.test query) + { searchUrl, description } = engines[keyword] + custom: true + searchUrl: searchUrl + description: description + queryTerms: queryTerms[1..] + else + custom: false + searchUrl: Settings.get "searchUrl" + description: "search" + queryTerms: queryTerms + + query = queryTerms.join " " + haveCompletionEngine = CompletionEngines.haveCompletionEngine searchUrl + + # This distinguishes two very different kinds of vomnibar baviours, the newer bahviour (true) and the + # legacy behavior (false). We retain the latter for the default search engine, and for custom search + # engines for which we do not have a completion engine. + version2 = custom and haveCompletionEngine + + # If this is a custom search engine and we have a completer, then we exclude results from other + # completers. + filter = if version2 then (suggestion) -> suggestion.type == description else null + + suggestions = [] + + # For custom search engines, we add a single, top-ranked entry for the unmodified query. This + # suggestion always appears at the top of the suggestion list. Its setting serve to define various + # vomnibar behaviors. + if custom + suggestions.push new Suggestion + queryTerms: queryTerms + type: description + url: Utils.createSearchUrl queryTerms, searchUrl + title: query + relevancy: 1 + insertText: if version2 then query else null + # We suppress the leading keyword, for example "w something" becomes "something" in the vomnibar. + suppressLeadingKeyword: true + # Should we highlight (via the selection) the longest continuation of the current query which is + # contained in all completions? + completeSuggestions: version2 + # Toggles for the legacy behaviour. + autoSelect: not version2 + forceAutoSelect: not version2 + highlightTerms: not version2 + + # Post suggestions and bail if there is no prospect of adding further suggestions. + if queryTerms.length == 0 or not haveCompletionEngine + return onComplete suggestions, { filter } + + # Post any initial suggestion, and then deliver suggestions from completion engines as a continuation + # (so, asynchronously). + onComplete suggestions, + filter: filter + continuation: (existingSuggestions, onComplete) => + suggestions = [] + # Relevancy: + # - Relevancy does not depend upon the actual suggestion (so, it does not depend upon word + # relevancy). We assume that the completion engine has already factored that in. Also, completion + # engines often handle spelling mistakes, in which case we wouldn't find the query terms in the + # suggestion anyway. + # - The relavancy is higher if the query term is longer. The idea is that search suggestions are more + # likely to be relevant if, after typing some number of characters, the user hasn't yet found + # a useful suggestion from another completer. + # - Scores are weighted such that they retain the order provided by the completion engine. + characterCount = query.length - queryTerms.length + 1 + relavancy = 0.6 * (Math.min(characterCount, 10.0)/10.0) + + if 0 < existingSuggestions.length + existingSuggestionsMinScore = existingSuggestions[existingSuggestions.length-1].relevancy + if relavancy < existingSuggestionsMinScore and MultiCompleter.maxResults <= existingSuggestions.length + # No suggestion we propose will have a high enough relavancy to beat the existing suggestions, so bail + # immediately. + return onComplete [] + + CompletionEngines.complete searchUrl, queryTerms, (completionSuggestions = []) => + for suggestion in completionSuggestions + suggestions.push new Suggestion + queryTerms: queryTerms + type: description + url: Utils.createSearchUrl suggestion, searchUrl + title: suggestion + relevancy: relavancy *= 0.9 + highlightTerms: false + insertText: suggestion + + # We keep at least three suggestions (if possible) and at most six. We keep more than three only if + # there are enough slots. The idea is that these suggestions shouldn't wholly displace suggestions + # from other completers. That would potentially be a problem because there is no relationship + # between the relevancy scores produced here and those produced by other completers. + count = Math.min 6, Math.max 3, MultiCompleter.maxResults - existingSuggestions.length + onComplete suggestions[...count] + + cancel: -> + CompletionEngines.cancel() # 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. diff --git a/background_scripts/settings.coffee b/background_scripts/settings.coffee index 44ed897d..11f492d7 100644 --- a/background_scripts/settings.coffee +++ b/background_scripts/settings.coffee @@ -32,9 +32,6 @@ root.Settings = Settings = root.Commands.parseCustomKeyMappings value root.refreshCompletionKeysAfterMappingSave() - searchEngines: (value) -> - root.SearchEngineCompleter.parseSearchEngines value - exclusionRules: (value) -> root.Exclusions.postUpdateHook value diff --git a/lib/utils.coffee b/lib/utils.coffee index 4c2a7a14..51b16351 100644 --- a/lib/utils.coffee +++ b/lib/utils.coffee @@ -260,6 +260,22 @@ class SimpleCache delete @previous[key] value +# This is a simple class for the common case where we want to use some data value which may be immediately +# available, or we may have to wait. It implements the use-immediately-or-wait queue, and calls the function +# to fetch the data asynchronously. +class AsyncDataFetcher + constructor: (fetch) -> + @data = null + @queue = [] + fetch (@data) => + Utils.nextTick => + callback @data for callback in @queue + @queue = null + + use: (callback) -> + if @data? then callback @data else @queue.push callback + root = exports ? window root.Utils = Utils root.SimpleCache = SimpleCache +root.AsyncDataFetcher = AsyncDataFetcher diff --git a/tests/unit_tests/completion_test.coffee b/tests/unit_tests/completion_test.coffee index b45c99dd..7711dac4 100644 --- a/tests/unit_tests/completion_test.coffee +++ b/tests/unit_tests/completion_test.coffee @@ -236,27 +236,6 @@ context "tab completer", assert.arrayEqual ["tab2.com"], results.map (tab) -> tab.url assert.arrayEqual [2], results.map (tab) -> tab.tabId -context "search engines", - setup -> - searchEngines = "foo: bar?q=%s\n# comment\nbaz: qux?q=%s baz description" - Settings.set 'searchEngines', searchEngines - @completer = new SearchEngineCompleter() - # note, I couldn't just call @completer.refresh() here as I couldn't set root.Settings without errors - # workaround is below, would be good for someone that understands the testing system better than me to improve - @completer.searchEngines = SearchEngineCompleter.getSearchEngines() - - should "return search engine suggestion without description", -> - results = filterCompleter(@completer, ["foo", "hello"]) - assert.arrayEqual ["bar?q=hello"], results.map (result) -> result.url - assert.arrayEqual ["hello"], results.map (result) -> result.title - assert.arrayEqual ["search [foo]"], results.map (result) -> result.type - - should "return search engine suggestion with description", -> - results = filterCompleter(@completer, ["baz", "hello"]) - # assert.arrayEqual ["qux?q=hello"], results.map (result) -> result.searchUrl - # assert.arrayEqual ["hello"], results.map (result) -> result.title - # assert.arrayEqual ["baz description"], results.map (result) -> result.type - context "suggestions", should "escape html in page titles", -> suggestion = new Suggestion diff --git a/tests/unit_tests/settings_test.coffee b/tests/unit_tests/settings_test.coffee index 52eec30d..4cd20211 100644 --- a/tests/unit_tests/settings_test.coffee +++ b/tests/unit_tests/settings_test.coffee @@ -70,14 +70,5 @@ context "settings", chrome.storage.sync.set { scrollStepSize: JSON.stringify(message) } assert.equal message, Sync.message - should "set search engines, retrieve them correctly and check that they have been parsed correctly", -> - searchEngines = "foo: bar?q=%s\n# comment\nbaz: qux?q=%s baz description" - Settings.set 'searchEngines', searchEngines - result = SearchEngineCompleter.getSearchEngines() - assert.equal "bar?q=%s", result["foo"].searchUrl - assert.isFalse result["foo"].description - assert.equal "qux?q=%s", result["baz"].searchUrl - assert.equal "baz description", result["baz"].description - should "sync a key which is not a known setting (without crashing)", -> chrome.storage.sync.set { notASetting: JSON.stringify("notAUsefullValue") } |
