diff options
| -rw-r--r-- | background_scripts/completion.coffee | 121 | ||||
| -rw-r--r-- | background_scripts/search_engines.coffee | 85 | ||||
| -rw-r--r-- | tests/unit_tests/completion_test.coffee | 4 | ||||
| -rw-r--r-- | tests/unit_tests/settings_test.coffee | 2 |
4 files changed, 111 insertions, 101 deletions
diff --git a/background_scripts/completion.coffee b/background_scripts/completion.coffee index fee4778a..37e9ea6b 100644 --- a/background_scripts/completion.coffee +++ b/background_scripts/completion.coffee @@ -335,42 +335,67 @@ class SearchEngineCompleter else (string) -> Utils.createSearchUrl string.split /\s+/ - type = if description? then description else "search" + haveDescription = description? and 0 < description.trim().length + type = if haveDescription then description else "search" searchUrl = if custom then url else Settings.get "searchUrl" - query = queryTerms[(if custom then 1 else 0)..].join " " # For custom search engines, we add an auto-selected suggestion. if custom - title = if description? then query else queryTerms[0] + ": " + query + query = queryTerms[1..].join " " + title = if haveDescription then query else keyword + ": " + query suggestions.push @mkSuggestion false, queryTerms, type, mkUrl(query), title, @computeRelevancy, 1 suggestions[0].autoSelect = true queryTerms = queryTerms[1..] - onComplete suggestions, (onComplete) => + if queryTerms.length == 0 + return onComplete suggestions + + onComplete suggestions, (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). - SearchEngines.complete searchUrl, queryTerms, (searchSuggestions = []) => - - # Scoring: - # - The score 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. - # - The score is higher if the query is longer. The idea is that search suggestions are more likely - # to be relevant if, after typing quite some number of characters, the user hasn't yet found a - # useful suggestion from another completer. - # - Scores are weighted such that they retain the ordering provided by the completion engine. - characterCount = query.length - queryTerms.length + 1 - score = 0.8 * (Math.min(characterCount, 12.0)/12.0) - - for suggestion in searchSuggestions - suggestions.push @mkSuggestion true, queryTerms, type, mkUrl(suggestion), suggestion, @computeRelevancy, score - score *= 0.9 - if custom - for suggestion in suggestions - suggestion.reinsertPrefix = "#{keyword} " if suggestion.insertText - - onComplete suggestions + # Scoring: + # - The score 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 score is based on the length of the last query term. The idea is that the user is already + # happy with the earlier terms. + # - The score is higher if the last 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 = queryTerms[queryTerms.length - 1].length + score = 0.6 * (Math.min(characterCount, 10.0)/10.0) + + if 0 < existingSuggestions.length + existingSuggestionMinScore = existingSuggestions[existingSuggestions.length-1].relevancy + if score < existingSuggestionMinScore and MultiCompleter.maxResults <= existingSuggestions.length + # No suggestion we propose will have a high enough score to beat the existing suggestions, so bail + # immediately. + return onComplete [] + + # We pause in case the user is still typing. + Utils.setTimeout 250, handler = @mostRecentHandler = => + return onComplete [] if handler != @mostRecentHandler # Bail if another completion has begun. + + SearchEngines.complete searchUrl, queryTerms, (searchSuggestions = []) => + for suggestion in searchSuggestions + suggestions.push @mkSuggestion true, queryTerms, type, mkUrl(suggestion), suggestion, @computeRelevancy, score + score *= 0.9 + + if custom + # For custom search engines, we need to tell the front end to insert the search engine's keyword + # when copying a suggestion into the vomnibar. + suggestion.reinsertPrefix = "#{keyword} " for suggestion in suggestions + + # 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] mkSuggestion: (insertText, args...) -> suggestion = new Suggestion args... @@ -412,8 +437,10 @@ class SearchEngineCompleter # 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 + @maxResults: 10 + constructor: (@completers) -> - @maxResults = 10 + @maxResults = MultiCompleter.maxResults refresh: -> completer.refresh?() for completer in @completers @@ -429,36 +456,30 @@ class MultiCompleter suggestions = [] completersFinished = 0 continuation = null + # Call filter() on every source completer and wait for them all to finish before returning results. + # At most one of the completers (SearchEngineCompleter) may pass a continuation function, which will be + # called after the results of all of the other completers have been posted. Any additional results + # from this continuation will be added to the existing results and posted later. We don't call the + # continuation if another query is already waiting. for completer in @completers - # Call filter() on every source completer and wait for them all to finish before returning results. - # At most one of the completers (SearchEngineCompleter) may pass a continuation function, which will be - # called asynchronously after the results of all of the other completers have been posted. Any - # additional results from this continuation will be added to the existing results and posted. We don't - # call the continuation if another query is already waiting. - completer.filter queryTerms, (newSuggestions, cont = null) => - # Allow completers to execute concurrently. + do (completer) => Utils.nextTick => - suggestions = suggestions.concat newSuggestions - continuation = cont if cont? - completersFinished += 1 - if completersFinished >= @completers.length - onComplete @prepareSuggestions(suggestions), keepAlive: continuation? - onDone = => + completer.filter queryTerms, (newSuggestions, cont = null) => + suggestions = suggestions.concat newSuggestions + continuation = cont if cont? + if @completers.length <= ++completersFinished + shouldRunContinuation = continuation? and not @mostRecentQuery + onComplete @prepareSuggestions(queryTerms, suggestions), keepAlive: shouldRunContinuation + # Allow subsequent queries to begin. @filterInProgress = false - @filter @mostRecentQuery.queryTerms, @mostRecentQuery.onComplete if @mostRecentQuery - # We add a very short delay. It is possible for all of this processing to have been handled - # pretty-much synchronously, which would have prevented any newly-arriving queries from - # registering. - Utils.setTimeout 10, => - if continuation? and not @mostRecentQuery - continuation (newSuggestions) => - onComplete @prepareSuggestions suggestions.concat(newSuggestions) - onDone() + if shouldRunContinuation + continuation suggestions, (newSuggestions) => + onComplete @prepareSuggestions queryTerms, suggestions.concat(newSuggestions) else - onDone() + @filter @mostRecentQuery.queryTerms, @mostRecentQuery.onComplete if @mostRecentQuery - prepareSuggestions: (suggestions) -> - suggestion.computeRelevancy @queryTerms for suggestion in suggestions + prepareSuggestions: (queryTerms, suggestions) -> + suggestion.computeRelevancy queryTerms for suggestion in suggestions suggestions.sort (a, b) -> b.relevancy - a.relevancy suggestions = suggestions[0...@maxResults] suggestion.generateHtml() for suggestion in suggestions diff --git a/background_scripts/search_engines.coffee b/background_scripts/search_engines.coffee index abf8c86e..3ddbe742 100644 --- a/background_scripts/search_engines.coffee +++ b/background_scripts/search_engines.coffee @@ -114,38 +114,16 @@ completionEngines = [ ] SearchEngines = - cancel: (searchUrl, callback = null) -> - @requests[searchUrl]?.abort() - delete @requests[searchUrl] - callback? null - - # Perform an HTTP GET. get: (searchUrl, url, callback) -> - @requests ?= {} # Maps a searchUrl to any outstanding HTTP request for that search engine. - @cancel searchUrl - - # We cache the results of the most-recent 100 successfully XMLHttpRequests with a ten-second (ie. very - # short) expiry. - @requestCache ?= new SimpleCache 10 * 1000, 100 - - if @requestCache.has url - callback @requestCache.get url - return - - @requests[searchUrl] = xhr = new XMLHttpRequest() + xhr = new XMLHttpRequest() xhr.open "GET", url, true - xhr.timeout = 750 - xhr.ontimeout = => @cancel searchUrl, callback - xhr.onerror = => @cancel searchUrl, callback + xhr.timeout = 1000 + xhr.ontimeout = xhr.onerror = -> callback null xhr.send() - xhr.onreadystatechange = => + xhr.onreadystatechange = -> if xhr.readyState == 4 - @requests[searchUrl] = null - if xhr.status == 200 - callback @requestCache.set url, xhr - else - callback null + callback(if xhr.status == 200 then xhr else null) # Look up the search-completion engine for this searchUrl. Because of DummySearchEngine, above, we know # there will always be a match. Imagining that there may be many completion engines, and knowing that this @@ -176,7 +154,7 @@ SearchEngines = return callback [] if Utils.hasJavascriptPrefix queryTerms[0] # Cache completions. However, completions depend upon both the searchUrl and the query terms. So we need - # to generate a key. We mix in some nonsense generated by pwgen. A key clash is possible, but vanishingly + # to generate a key. We mix in some junk generated by pwgen. A key clash is possible, but vanishingly # unlikely. junk = "//Zi?ei5;o//" completionCacheKey = searchUrl + junk + queryTerms.join junk @@ -184,26 +162,37 @@ SearchEngines = if @completionCache.has completionCacheKey return callback @completionCache.get completionCacheKey - engine = @lookupEngine searchUrl - url = engine.getUrl queryTerms - query = queryTerms.join(" ").toLowerCase() - @get searchUrl, url, (xhr = null) => - # Parsing the response may fail if we receive an unexpected or an unexpectedly-formatted response. In - # all cases, we fall back to the catch clause, below. - try - suggestions = engine.parse xhr - # Make sure we really do have an iterable of strings. - suggestions = (suggestion for suggestion in suggestions when "string" == typeof suggestion) - # Filter out the query itself. It's not adding anything. - suggestions = (suggestion for suggestion in suggestions when suggestion.toLowerCase() != query) - # We keep at most three suggestions, the top three. - callback @completionCache.set completionCacheKey, suggestions[...3] - catch - callback @completionCache.set completionCacheKey, callback [] - # We cache failures, but remove them after just ten minutes. This (it is hoped) avoids repeated - # XMLHttpRequest failures over a short period of time. - removeCompletionCacheKey = => @completionCache.set completionCacheKey, null - setTimeout removeCompletionCacheKey, 10 * 60 * 1000 # Ten minutes. + fetchSuggestions = (callback) => + engine = @lookupEngine searchUrl + url = engine.getUrl queryTerms + query = queryTerms.join(" ").toLowerCase() + @get searchUrl, url, (xhr = null) => + # Parsing the response may fail if we receive an unexpected or an unexpectedly-formatted response. In + # all cases, we fall back to the catch clause, below. + try + suggestions = engine.parse xhr + # Make sure we really do have an iterable of strings. + suggestions = (suggestion for suggestion in suggestions when "string" == typeof suggestion) + # Filter out the query itself. It's not adding anything. + suggestions = (suggestion for suggestion in suggestions when suggestion.toLowerCase() != query) + catch + suggestions = [] + # We cache failures, but remove them after just ten minutes. This (it is hoped) avoids repeated + # XMLHttpRequest failures over a short period of time. + removeCompletionCacheKey = => @completionCache.set completionCacheKey, null + setTimeout removeCompletionCacheKey, 10 * 60 * 1000 # Ten minutes. + + callback suggestions + + # Don't allow duplicate identical active requests. This can happen, for example, when the user enters or + # removes a space, or when they enter a character and immediately delete it. + @inTransit ?= {} + unless @inTransit[completionCacheKey]?.push callback + queue = @inTransit[completionCacheKey] = [] + fetchSuggestions (suggestions) => + callback @completionCache.set completionCacheKey, suggestions + delete @inTransit[completionCacheKey] + callback suggestions for callback in queue root = exports ? window root.SearchEngines = SearchEngines diff --git a/tests/unit_tests/completion_test.coffee b/tests/unit_tests/completion_test.coffee index 39437b52..56fcc456 100644 --- a/tests/unit_tests/completion_test.coffee +++ b/tests/unit_tests/completion_test.coffee @@ -239,10 +239,10 @@ context "search engines", setup -> searchEngines = "foo: bar?q=%s\n# comment\nbaz: qux?q=%s baz description" Settings.set 'searchEngines', searchEngines - @completer = new CustomSearchEngineCompleter() + @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 = CustomSearchEngineCompleter.getSearchEngines() + @completer.searchEngines = SearchEngineCompleter.getSearchEngines() should "return search engine suggestion without description", -> results = filterCompleter(@completer, ["foo", "hello"]) diff --git a/tests/unit_tests/settings_test.coffee b/tests/unit_tests/settings_test.coffee index a67b69fa..346c98da 100644 --- a/tests/unit_tests/settings_test.coffee +++ b/tests/unit_tests/settings_test.coffee @@ -73,7 +73,7 @@ context "settings", 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 = CustomSearchEngineCompleter.getSearchEngines() + result = SearchEngineCompleter.getSearchEngines() assert.equal Object.keys(result).length, 2 assert.equal "bar?q=%s", result["foo"].url assert.isFalse result["foo"].description |
