com.google.gwt.user.client.ui.MultiWordSuggestOracle Maven / Gradle / Ivy
/*
* 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.safehtml.shared.SafeHtmlUtils;
import com.google.gwt.safehtml.shared.annotations.IsSafeHtml;
import com.google.gwt.user.client.rpc.IsSerializable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.TreeSet;
/**
* 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.
*
* Example Table
*
*
*
*
* All Suggestions
* Query string
* Matching Suggestions
*
*
* John Smith, Joe Brown, Jane Doe, Jane Smith, Bob Jones
* Jo
* John Smith, Joe Brown, Bob Jones
*
*
* John Smith, Joe Brown, Jane Doe, Jane Smith, Bob Jones
* Smith
* John Smith, Jane Smith
*
*
* Georgia, New York, California
* g
* Georgia
*
*
*
*/
public class MultiWordSuggestOracle extends SuggestOracle {
/**
* Suggestion class for {@link MultiWordSuggestOracle}.
*/
public static class MultiWordSuggestion implements Suggestion, IsSerializable {
@IsSafeHtml private String displayString;
private String replacementString;
/**
* Constructor used by RPC.
*/
public MultiWordSuggestion() {
}
/**
* Constructor for MultiWordSuggestion
.
*
* @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, @IsSafeHtml String displayString) {
this.replacementString = replacementString;
this.displayString = displayString;
}
@IsSafeHtml
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 {
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> toCandidates = new HashMap>();
/**
* Associates candidates with their formatted suggestions. Multiple formatted
* suggestions could be normalized to the same candidate, e.g. both 'Mobile'
* and 'MOBILE' are normalized to 'mobile'.
*/
private HashMap> toRealSuggestions =
new HashMap<>();
/**
* Specifies whether all formatted suggestions should be returned per
* normalized candidate. Refer ro {@link #setSuggestAllMatchingWords}
* for more details.
*/
private boolean suggestAllMatchingWords = false;
/**
* The whitespace masks used to prevent matching and replacing of the given
* substrings.
*/
private char[] whitespaceChars;
private Response defaultResponse;
/*
* Comparator used for sorting candidates from search.
*/
private Comparator comparator = null;
/**
* Constructor for MultiWordSuggestOracle
. This uses a space as
* the whitespace character.
*
* @see #MultiWordSuggestOracle(String)
*/
public MultiWordSuggestOracle() {
this(" ");
}
/**
* Constructor for MultiWordSuggestOracle
which takes in a set of
* whitespace chars that filter its input.
*
* Example: If ".,"
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".
*
*
* @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.
List realSuggestions = toRealSuggestions.get(candidate);
if (realSuggestions == null) {
realSuggestions = new ArrayList();
toRealSuggestions.put(candidate, realSuggestions);
}
realSuggestions.add(0, 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 l = toCandidates.get(word);
if (l == null) {
l = new HashSet();
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 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 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 suggestions =
convertToFormattedSuggestions(query, candidates);
Response response = new Response(suggestions);
response.setMoreSuggestionsCount(numberTruncated);
callback.onSuggestionsReady(request, response);
}
/**
* Sets the comparator used for sorting candidates from search.
*
* @param comparator the comparator to use.
*/
public void setComparator(Comparator comparator) {
this.comparator = comparator;
}
/**
* Sets the default suggestion collection.
*
* @param suggestionList the default list of suggestions
*/
public void setDefaultSuggestions(Collection 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 suggestionList) {
Collection accum = new ArrayList();
for (String candidate : suggestionList) {
accum.add(createSuggestion(candidate, SafeHtmlUtils.htmlEscape(candidate)));
}
setDefaultSuggestions(accum);
}
/**
* Sets the flag on whether to suggest all matching words. With words
* 'Mobile', 'MOBILE', 'mobile', 'MoBILE', typing 'm' will only build one
* suggestion for 'MoBILE' if {@code suggestAllMatchingWords} is
* {@code false}. However, it will build suggestions for all four words if
* {@code suggestAllMatchingWords} is {@code true}.
*
* @param suggestAllMatchingWords true to return all formatted suggestions
* per normalized candidate, false to return the last formatted
* suggestions per normalized candidate.
*/
public final void setSuggestAllMatchingWords(boolean suggestAllMatchingWords) {
this.suggestAllMatchingWords = suggestAllMatchingWords;
}
/**
* 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, @IsSafeHtml String displayString) {
return new MultiWordSuggestion(replacementString, displayString);
}
/**
* Returns real suggestions with the given query in strong
html
* font.
*
* @param query query string
* @param candidates candidates
* @return real suggestions
*/
private List convertToFormattedSuggestions(String query,
List candidates) {
List suggestions = new ArrayList();
for (int i = 0; i < candidates.size(); i++) {
String candidate = candidates.get(i);
// Use real suggestion for assembly.
List realSuggestions = toRealSuggestions.get(candidate);
TreeSet realSuggestionsSet = new TreeSet<>();
if (suggestAllMatchingWords) {
realSuggestionsSet.addAll(realSuggestions);
} else {
// Build only one suggestion per normalized candidate if
// suggestAllMatchingWords is false.
realSuggestionsSet.add(realSuggestions.get(0));
}
Iterator realSuggestionsIterator = realSuggestionsSet.iterator();
while (realSuggestionsIterator.hasNext()) {
int cursor = 0;
int index = 0;
String formattedSuggestion = realSuggestionsIterator.next();
// 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("");
accum.appendEscaped(part2);
accum.appendHtmlConstant("");
}
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 createCandidatesFromSearch(String query) {
ArrayList candidates = new ArrayList();
if (query.length() == 0) {
return candidates;
}
// Find all words to search for.
String[] searchWords = query.split(WHITESPACE_STRING);
HashSet 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 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, comparator);
}
return candidates;
}
/**
* Creates a set of potential candidates that match the given query.
*
* @param query query string
* @return possible candidates
*/
private HashSet createCandidatesFromWord(String query) {
HashSet candidateSet = new HashSet();
List 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 belongsTo = toCandidates.get(words.get(i));
if (belongsTo != null) {
candidateSet.addAll(belongsTo);
}
}
}
return candidateSet;
}
/**
* Returns 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(Locale.ROOT);
// 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;
}
}