// This file is part of OpenTSDB. // Copyright (C) 2010-2012 The OpenTSDB Authors. // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU Lesser General Public License as published by // the Free Software Foundation, either version 2.1 of the License, or (at your // option) any later version. This program is distributed in the hope that it // will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty // of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser // General Public License for more details. You should have received a copy // of the GNU Lesser General Public License along with this program. If not, // see <http://www.gnu.org/licenses/>. package tsd.client; import java.util.HashMap; import com.google.gwt.http.client.RequestBuilder; import com.google.gwt.http.client.RequestCallback; import com.google.gwt.http.client.RequestException; import com.google.gwt.json.client.JSONArray; import com.google.gwt.json.client.JSONParser; import com.google.gwt.json.client.JSONValue; import com.google.gwt.user.client.ui.HasText; import com.google.gwt.user.client.ui.MultiWordSuggestOracle; import com.google.gwt.user.client.ui.SuggestBox; import com.google.gwt.user.client.ui.SuggestOracle; import com.google.gwt.user.client.ui.TextBoxBase; /** * An oracle that gets suggestions through an AJAX call and provides caching. * * The oracle builds up a local cache of known suggestions and tries to avoid * unnecessary requests when the cache can be used (which is fairly frequent * given the typing pattern) or when we know for sure there won't be any * results. * * The oracle is given a type. Every instance that share the same type also * share the same caches under the hood. This is convenient when you want to * have multiple text boxes with the same type of suggestions. */ final class RemoteOracle extends SuggestOracle { private static final String SUGGEST_URL = "/suggest?type="; // + type&q=foo /** * Maps an oracle type to its suggestion cache. * The cache is in fact a {@link MultiWordSuggestOracle}, which we re-use as * its implementation is good (it uses a trie, handles HTML formatting etc.). */ private static final HashMap<String, MultiWordSuggestOracle> caches = new HashMap<String, MultiWordSuggestOracle>(); /** Maps an oracle type to the queries recently seen for this type. */ private static final HashMap<String, QueriesSeen> all_queries_seen = new HashMap<String, QueriesSeen>(); private final String type; private final MultiWordSuggestOracle cache; private final QueriesSeen queries_seen; /** Which widget are we wrapping to provide suggestions. */ private HasText requester; /** Current ongoing request, or null. */ private Callback current; /** * Pending request that arrived while we were still processing `current'. * If requests keep coming in while we're processing `current', the last * pending one will overwrite the previous pending one. */ private Request pending_req; private Callback pending_cb; /** Used to guess whether we need to fetch more suggestions. */ private String last_query; private String last_suggestion; /** * Factory method to use in order to get a {@link RemoteOracle} instance. * @param suggest_type The type of suggestion wanted. * @param textbox The text box to wrap to provide suggestions to. */ public static SuggestBox newSuggestBox(final String suggest_type, final TextBoxBase textbox) { final RemoteOracle oracle = new RemoteOracle(suggest_type); final SuggestBox box = new SuggestBox(oracle, textbox); oracle.requester = box; return box; } /** Private constructor, use {@link #newSuggestBox} instead. */ private RemoteOracle(final String suggest_type) { type = suggest_type; MultiWordSuggestOracle cache = caches.get(type); QueriesSeen queries_seen; if (cache == null) { cache = new MultiWordSuggestOracle("."); queries_seen = new QueriesSeen(); caches.put(type, cache); all_queries_seen.put(type, queries_seen); } else { queries_seen = all_queries_seen.get(type); } this.cache = cache; this.queries_seen = queries_seen; } @Override public boolean isDisplayStringHTML() { return true; } @Override public void requestSuggestions(final Request request, final Callback callback) { if (current != null) { pending_req = request; pending_cb = callback; return; } current = callback; { final String this_query = request.getQuery(); // Check if we can serve this from our local cache, without even talking // to the server. This is possible if either of those is true: // 1. We've already seen this query recently. // 2. This new query precedes another one and the user basically just // typed another letter, so if the new query is "less than" the last // result we got from the server, we know we already cached the full // range of results covering the new request. if ((last_query != null && last_query.compareTo(this_query) <= 0 && this_query.compareTo(last_suggestion) < 0) || queries_seen.check(this_query)) { current = null; cache.requestSuggestions(request, callback); return; } last_query = this_query; } final RequestBuilder builder = new RequestBuilder(RequestBuilder.GET, SUGGEST_URL + type + "&q=" + last_query); try { builder.sendRequest(null, new RequestCallback() { public void onError(final com.google.gwt.http.client.Request r, final Throwable e) { current = null; // Something bad happened, drop the current request. if (pending_req != null) { // But if we have another waiting... requestSuggestions(pending_req, pending_cb); // ... try it now. } } // Need to use fully-qualified names as this class inherits already // from a pair of inner classes called Request / Response :-/ public void onResponseReceived(final com.google.gwt.http.client.Request r, final com.google.gwt.http.client.Response response) { if (response.getStatusCode() == com.google.gwt.http.client.Response.SC_OK) { final JSONValue json = JSONParser.parse(response.getText()); // In case this request returned nothing, we pretend the last // suggestion ended with the largest character possible, so we // won't send more requests to the server if the user keeps // adding extra characters. last_suggestion = last_query + "\377"; if (json != null && json.isArray() != null) { final JSONArray results = json.isArray(); final int n = Math.min(request.getLimit(), results.size()); for (int i = 0; i < n; i++) { final JSONValue suggestion = results.get(i); if (suggestion == null || suggestion.isString() == null) { continue; } final String suggestionstr = suggestion.isString().stringValue(); last_suggestion = suggestionstr; cache.add(suggestionstr); } // Is this response still relevant to what the requester wants? if (requester.getText().startsWith(last_query)) { cache.requestSuggestions(request, callback); pending_req = null; pending_cb = null; } } } current = null; // Regardless of what happened above, this is done. if (pending_req != null) { final Request req = pending_req; final Callback cb = pending_cb; pending_req = null; pending_cb = null; requestSuggestions(req, cb); } } }); } catch (RequestException ignore) { } } /** Small circular buffer of queries already typed by the user. */ private static final class QueriesSeen { /** * A circular buffer containing the last few requests already served. * It would be awesome if {@code gwt.user.client.ui.PrefixTree} wasn't * package-private, so we could use that instead. */ private final String[] already_requested = new String[128]; private int already_index; // Index into already_index. /** * Checks whether or not we've already seen that query. */ boolean check(final String query) { // Check most recent queries first, as they're the most likely to match // if the user goes back and forth by typing a few characters, removing // some, typing some more, etc. for (int i = already_index - 1; i >= 0; i--) { if (query.equals(already_requested[i])) { return true; } } for (int i = already_requested.length - 1; i >= already_index; i--) { if (query.equals(already_requested[i])) { return true; } } // First time we see this query, let's record it. already_requested[already_index++] = query; if (already_index == already_requested.length) { already_index = 0; } return false; } } }