blob: ea960ce642f4f5586115d015eba58d7160bf6eba [file] [log] [blame]
/*
* Copyright 2007 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.google.gwt.user.client.ui;
import com.google.gwt.safehtml.shared.SafeHtmlBuilder;
import com.google.gwt.user.client.rpc.IsSerializable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* The default {@link com.google.gwt.user.client.ui.SuggestOracle}. The default
* oracle returns potential suggestions based on breaking the query into
* separate words and looking for matches. It also modifies the returned text to
* show which prefix matched the query term. The matching is case insensitive.
* All suggestions are sorted before being passed into a response.
* <p>
* Example Table
* </p>
* <p>
* <table width = "100%" border = "1">
* <tr>
* <td><b> All Suggestions </b></td>
* <td><b>Query string</b></td>
* <td><b>Matching Suggestions</b></td>
* </tr>
* <tr>
* <td>John Smith, Joe Brown, Jane Doe, Jane Smith, Bob Jones</td>
* <td>Jo</td>
* <td>John Smith, Joe Brown, Bob Jones</td>
* </tr>
* <tr>
* <td>John Smith, Joe Brown, Jane Doe, Jane Smith, Bob Jones</td>
* <td>Smith</td>
* <td>John Smith, Jane Smith</td>
* </tr>
* <tr>
* <td>Georgia, New York, California</td>
* <td>g</td>
* <td>Georgia</td>
* </tr>
* </table>
* </p>
*/
public class MultiWordSuggestOracle extends SuggestOracle {
/**
* Suggestion class for {@link MultiWordSuggestOracle}.
*/
public static class MultiWordSuggestion implements Suggestion, IsSerializable {
private String displayString;
private String replacementString;
/**
* Constructor used by RPC.
*/
public MultiWordSuggestion() {
}
/**
* Constructor for <code>MultiWordSuggestion</code>.
*
* @param replacementString the string to enter into the SuggestBox's text
* box if the suggestion is chosen
* @param displayString the display string
*/
public MultiWordSuggestion(String replacementString, String displayString) {
this.replacementString = replacementString;
this.displayString = displayString;
}
public String getDisplayString() {
return displayString;
}
public String getReplacementString() {
return replacementString;
}
}
/**
* A class reresenting the bounds of a word within a string.
*
* The bounds are represented by a {@code startIndex} (inclusive) and
* an {@code endIndex} (exclusive).
*/
private static class WordBounds implements Comparable<WordBounds> {
final int startIndex;
final int endIndex;
public WordBounds(int startIndex, int length) {
this.startIndex = startIndex;
this.endIndex = startIndex + length;
}
public int compareTo(WordBounds that) {
int comparison = this.startIndex - that.startIndex;
if (comparison == 0) {
comparison = that.endIndex - this.endIndex;
}
return comparison;
}
}
private static final char WHITESPACE_CHAR = ' ';
private static final String WHITESPACE_STRING = " ";
/**
* Regular expression used to collapse all whitespace in a query string.
*/
private static final String NORMALIZE_TO_SINGLE_WHITE_SPACE = "\\s+";
/**
* Associates substrings with words.
*/
private final PrefixTree tree = new PrefixTree();
/**
* Associates individual words with candidates.
*/
private HashMap<String, Set<String>> toCandidates = new HashMap<String, Set<String>>();
/**
* Associates candidates with their formatted suggestions.
*/
private HashMap<String, String> toRealSuggestions = new HashMap<String, String>();
/**
* The whitespace masks used to prevent matching and replacing of the given
* substrings.
*/
private char[] whitespaceChars;
private Response defaultResponse;
/**
* Constructor for <code>MultiWordSuggestOracle</code>. This uses a space as
* the whitespace character.
*
* @see #MultiWordSuggestOracle(String)
*/
public MultiWordSuggestOracle() {
this(" ");
}
/**
* Constructor for <code>MultiWordSuggestOracle</code> which takes in a set of
* whitespace chars that filter its input.
* <p>
* Example: If <code>".,"</code> is passed in as whitespace, then the string
* "foo.bar" would match the queries "foo", "bar", "foo.bar", "foo...bar", and
* "foo, bar". If the empty string is used, then all characters are used in
* matching. For example, the query "bar" would match "bar", but not "foo
* bar".
* </p>
*
* @param whitespaceChars the characters to treat as word separators
*/
public MultiWordSuggestOracle(String whitespaceChars) {
this.whitespaceChars = new char[whitespaceChars.length()];
for (int i = 0; i < whitespaceChars.length(); i++) {
this.whitespaceChars[i] = whitespaceChars.charAt(i);
}
}
/**
* Adds a suggestion to the oracle. Each suggestion must be plain text.
*
* @param suggestion the suggestion
*/
public void add(String suggestion) {
String candidate = normalizeSuggestion(suggestion);
// candidates --> real suggestions.
toRealSuggestions.put(candidate, suggestion);
// word fragments --> candidates.
String[] words = candidate.split(WHITESPACE_STRING);
for (int i = 0; i < words.length; i++) {
String word = words[i];
tree.add(word);
Set<String> l = toCandidates.get(word);
if (l == null) {
l = new HashSet<String>();
toCandidates.put(word, l);
}
l.add(candidate);
}
}
/**
* Adds all suggestions specified. Each suggestion must be plain text.
*
* @param collection the collection
*/
public final void addAll(Collection<String> collection) {
for (String suggestion : collection) {
add(suggestion);
}
}
/**
* Removes all of the suggestions from the oracle.
*/
public void clear() {
tree.clear();
toCandidates.clear();
toRealSuggestions.clear();
}
@Override
public boolean isDisplayStringHTML() {
return true;
}
@Override
public void requestDefaultSuggestions(Request request, Callback callback) {
if (defaultResponse != null) {
callback.onSuggestionsReady(request, defaultResponse);
} else {
super.requestDefaultSuggestions(request, callback);
}
}
@Override
public void requestSuggestions(Request request, Callback callback) {
String query = normalizeSearch(request.getQuery());
int limit = request.getLimit();
// Get candidates from search words.
List<String> candidates = createCandidatesFromSearch(query);
// Respect limit for number of choices.
int numberTruncated = Math.max(0, candidates.size() - limit);
for (int i = candidates.size() - 1; i > limit; i--) {
candidates.remove(i);
}
// Convert candidates to suggestions.
List<MultiWordSuggestion> suggestions =
convertToFormattedSuggestions(query, candidates);
Response response = new Response(suggestions);
response.setMoreSuggestionsCount(numberTruncated);
callback.onSuggestionsReady(request, response);
}
/**
* Sets the default suggestion collection.
*
* @param suggestionList the default list of suggestions
*/
public void setDefaultSuggestions(Collection<Suggestion> suggestionList) {
this.defaultResponse = new Response(suggestionList);
}
/**
* A convenience method to set default suggestions using plain text strings.
*
* Note to use this method each default suggestion must be plain text.
*
* @param suggestionList the default list of suggestions
*/
public final void setDefaultSuggestionsFromText(
Collection<String> suggestionList) {
Collection<Suggestion> accum = new ArrayList<Suggestion>();
for (String candidate : suggestionList) {
accum.add(createSuggestion(candidate, candidate));
}
setDefaultSuggestions(accum);
}
/**
* Creates the suggestion based on the given replacement and display strings.
*
* @param replacementString the string to enter into the SuggestBox's text box
* if the suggestion is chosen
* @param displayString the display string
*
* @return the suggestion created
*/
protected MultiWordSuggestion createSuggestion(String replacementString,
String displayString) {
return new MultiWordSuggestion(replacementString, displayString);
}
/**
* Returns real suggestions with the given query in <code>strong</code> html
* font.
*
* @param query query string
* @param candidates candidates
* @return real suggestions
*/
private List<MultiWordSuggestion> convertToFormattedSuggestions(String query,
List<String> candidates) {
List<MultiWordSuggestion> suggestions = new ArrayList<MultiWordSuggestion>();
for (int i = 0; i < candidates.size(); i++) {
String candidate = candidates.get(i);
int cursor = 0;
int index = 0;
// Use real suggestion for assembly.
String formattedSuggestion = toRealSuggestions.get(candidate);
// Create strong search string.
SafeHtmlBuilder accum = new SafeHtmlBuilder();
String[] searchWords = query.split(WHITESPACE_STRING);
while (true) {
WordBounds wordBounds = findNextWord(candidate, searchWords, index);
if (wordBounds == null) {
break;
}
if (wordBounds.startIndex == 0 ||
WHITESPACE_CHAR == candidate.charAt(wordBounds.startIndex - 1)) {
String part1 = formattedSuggestion.substring(cursor, wordBounds.startIndex);
String part2 = formattedSuggestion.substring(wordBounds.startIndex,
wordBounds.endIndex);
cursor = wordBounds.endIndex;
accum.appendEscaped(part1);
accum.appendHtmlConstant("<strong>");
accum.appendEscaped(part2);
accum.appendHtmlConstant("</strong>");
}
index = wordBounds.endIndex;
}
// Check to make sure the search was found in the string.
if (cursor == 0) {
continue;
}
accum.appendEscaped(formattedSuggestion.substring(cursor));
MultiWordSuggestion suggestion = createSuggestion(formattedSuggestion,
accum.toSafeHtml().asString());
suggestions.add(suggestion);
}
return suggestions;
}
/**
* Find the sorted list of candidates that are matches for the given query.
*/
private List<String> createCandidatesFromSearch(String query) {
ArrayList<String> candidates = new ArrayList<String>();
if (query.length() == 0) {
return candidates;
}
// Find all words to search for.
String[] searchWords = query.split(WHITESPACE_STRING);
HashSet<String> candidateSet = null;
for (int i = 0; i < searchWords.length; i++) {
String word = searchWords[i];
// Eliminate bogus word choices.
if (word.length() == 0 || word.matches(WHITESPACE_STRING)) {
continue;
}
// Find the set of candidates that are associated with all the
// searchWords.
HashSet<String> thisWordChoices = createCandidatesFromWord(word);
if (candidateSet == null) {
candidateSet = thisWordChoices;
} else {
candidateSet.retainAll(thisWordChoices);
if (candidateSet.size() < 2) {
// If there is only one candidate, on average it is cheaper to
// check if that candidate contains our search string than to
// continue intersecting suggestion sets.
break;
}
}
}
if (candidateSet != null) {
candidates.addAll(candidateSet);
Collections.sort(candidates);
}
return candidates;
}
/**
* Creates a set of potential candidates that match the given query.
*
* @param query query string
* @return possible candidates
*/
private HashSet<String> createCandidatesFromWord(String query) {
HashSet<String> candidateSet = new HashSet<String>();
List<String> words = tree.getSuggestions(query, Integer.MAX_VALUE);
if (words != null) {
// Find all candidates that contain the given word the search is a
// subset of.
for (int i = 0; i < words.size(); i++) {
Collection<String> belongsTo = toCandidates.get(words.get(i));
if (belongsTo != null) {
candidateSet.addAll(belongsTo);
}
}
}
return candidateSet;
}
/**
* @return a {@link WordBounds} representing the first word in
* {@code searchWords} that is found in candidate starting at
* {@code indexToStartAt} or {@code null} if no words could be found.
*/
private WordBounds findNextWord(String candidate, String[] searchWords, int indexToStartAt) {
WordBounds firstWord = null;
for (String word : searchWords) {
int index = candidate.indexOf(word, indexToStartAt);
if (index != -1) {
WordBounds newWord = new WordBounds(index, word.length());
if (firstWord == null || newWord.compareTo(firstWord) < 0) {
firstWord = newWord;
}
}
}
return firstWord;
}
/**
* Normalize the search key by making it lower case, removing multiple spaces,
* apply whitespace masks, and make it lower case.
*/
private String normalizeSearch(String search) {
// Use the same whitespace masks and case normalization for the search
// string as was used with the candidate values.
search = normalizeSuggestion(search);
// Remove all excess whitespace from the search string.
search = search.replaceAll(NORMALIZE_TO_SINGLE_WHITE_SPACE,
WHITESPACE_STRING);
return search.trim();
}
/**
* Takes the formatted suggestion, makes it lower case and blanks out any
* existing whitespace for searching.
*/
private String normalizeSuggestion(String formattedSuggestion) {
// Formatted suggestions should already have normalized whitespace. So we
// can skip that step.
// Lower case suggestion.
formattedSuggestion = formattedSuggestion.toLowerCase();
// Apply whitespace.
if (whitespaceChars != null) {
for (int i = 0; i < whitespaceChars.length; i++) {
char ignore = whitespaceChars[i];
formattedSuggestion = formattedSuggestion.replace(ignore,
WHITESPACE_CHAR);
}
}
return formattedSuggestion;
}
}