/**
* OLAT - Online Learning and Training<br>
* http://www.olat.org
* <p>
* Licensed under the Apache License, Version 2.0 (the "License"); <br>
* you may not use this file except in compliance with the License.<br>
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing,<br>
* software distributed under the License is distributed on an "AS IS" BASIS, <br>
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br>
* See the License for the specific language governing permissions and <br>
* limitations under the License.
* <p>
* Copyright (c) frentix GmbH<br>
* http://www.frentix.com<br>
* <p>
*/
package org.olat.search.ui;
import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.StringTokenizer;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.analysis.tokenattributes.TermAttribute;
import org.apache.lucene.queryParser.ParseException;
import org.apache.lucene.util.Version;
import org.olat.NewControllerFactory;
import org.olat.core.commons.services.search.OlatDocument;
import org.olat.core.commons.services.search.QueryException;
import org.olat.core.commons.services.search.ResultDocument;
import org.olat.core.commons.services.search.SearchResults;
import org.olat.core.commons.services.search.SearchService;
import org.olat.core.commons.services.search.ServiceNotAvailableException;
import org.olat.core.commons.services.search.ui.SearchController;
import org.olat.core.commons.services.search.ui.SearchEvent;
import org.olat.core.commons.services.search.ui.SearchServiceUIFactory;
import org.olat.core.commons.services.search.ui.SearchServiceUIFactory.DisplayOption;
import org.olat.core.gui.UserRequest;
import org.olat.core.gui.components.form.flexible.FormItem;
import org.olat.core.gui.components.form.flexible.FormItemContainer;
import org.olat.core.gui.components.form.flexible.elements.FormLink;
import org.olat.core.gui.components.form.flexible.elements.TextElement;
import org.olat.core.gui.components.form.flexible.impl.Form;
import org.olat.core.gui.components.form.flexible.impl.FormBasicController;
import org.olat.core.gui.components.form.flexible.impl.FormEvent;
import org.olat.core.gui.components.link.Link;
import org.olat.core.gui.control.Controller;
import org.olat.core.gui.control.Event;
import org.olat.core.gui.control.WindowControl;
import org.olat.core.gui.control.generic.closablewrapper.CloseableModalController;
import org.olat.core.gui.control.generic.closablewrapper.CloseableModalWindowController;
import org.olat.core.gui.media.RedirectMediaResource;
import org.olat.core.id.context.BusinessControl;
import org.olat.core.id.context.BusinessControlFactory;
import org.olat.core.logging.OLog;
import org.olat.core.logging.Tracing;
import org.olat.core.service.ServiceFactory;
import org.olat.core.util.StringHelper;
import org.olat.search.service.SearchServiceImpl;
/**
* Description:<br>
* Controller with a simple input for the full text search. The display option
* select how the input is shown: only a button, button with text, input field and
* button.
* <P>
* Initial Date: 3 dec. 2009 <br>
* @author srosse, stephane.rosse@frentix.com
*/
public class SearchInputController extends FormBasicController implements SearchController {
private static final OLog log = Tracing.createLoggerFor(SearchInputController.class);
private static final String FUZZY_SEARCH = "~0.7";
private static final String CMD_DID_YOU_MEAN_LINK = "didYouMeanLink-";
private static final String SEARCH_STORE_KEY = "search-store-key";
private static final String SEARCH_CACHE_KEY = "search-cache-key";
private String parentContext;
private String documentType;
private final String resourceUrl;
private boolean resourceContextEnable = true;
private SearchService searchService;
private DisplayOption displayOption;
protected FormLink searchButton;
protected TextElement searchInput;
private ResultsSearchController resultCtlr;
private Controller searchDialogBox;
protected List<FormLink> didYouMeanLinks;
private Map<String,Properties> prefs;
private SearchLRUCache searchCache;
public SearchInputController(UserRequest ureq, WindowControl wControl, String resourceUrl, DisplayOption displayOption) {
super(ureq, wControl, LAYOUT_HORIZONTAL);
this.resourceUrl = resourceUrl;
this.displayOption = displayOption;
setSearchStore(ureq);
initForm(ureq);
loadPersistedSearch();
loadContext();
}
public SearchInputController(UserRequest ureq, WindowControl wControl, String resourceUrl, String customPage) {
super(ureq, wControl, customPage);
this.displayOption = DisplayOption.STANDARD_TEXT;
this.resourceUrl = resourceUrl;
setSearchStore(ureq);
initForm(ureq);
loadPersistedSearch();
loadContext();
}
public SearchInputController(UserRequest ureq, WindowControl wControl, String resourceUrl, DisplayOption displayOption, Form mainForm) {
super(ureq, wControl, LAYOUT_HORIZONTAL, null, mainForm);
this.displayOption = displayOption;
this.resourceUrl = resourceUrl;
setSearchStore(ureq);
initForm(ureq);
loadPersistedSearch();
loadContext();
}
public String getParentContext() {
return parentContext;
}
public void setParentContext(String parentContext) {
this.parentContext = parentContext;
}
public String getDocumentType() {
return documentType;
}
public void setDocumentType(String documentType) {
this.documentType = documentType;
}
public String getResourceUrl() {
return resourceUrl;
}
public boolean isResourceContextEnable() {
return resourceContextEnable;
}
public void setResourceContextEnable(boolean resourceContextEnable) {
this.resourceContextEnable = resourceContextEnable;
}
public String getSearchString() {
return searchInput.getValue();
}
public void setSearchString(String searchString) {
if (StringHelper.containsNonWhitespace(searchString)) {
if(searchInput != null) {
searchInput.setValue(searchString);
}
}
}
@Override
protected void initForm(FormItemContainer formLayout, Controller listener, UserRequest ureq) {
searchService = SearchServiceImpl.getInstance();
if (displayOption.equals(DisplayOption.STANDARD) || displayOption.equals(DisplayOption.STANDARD_TEXT)) {
searchInput = uifactory.addTextElement("search_input", "search.title", 255, "", formLayout);
searchInput.setLabel(null, null);
}
if (displayOption.equals(DisplayOption.STANDARD) || displayOption.equals(DisplayOption.BUTTON)) {
searchButton = uifactory.addFormLink("search", "", "", formLayout, Link.NONTRANSLATED + Link.LINK_CUSTOM_CSS);
searchButton.setCustomEnabledLinkCSS("o_fulltext_search_button b_small_icon");
} else if (displayOption.equals(DisplayOption.BUTTON_WITH_LABEL)) {
searchButton = uifactory.addFormLink("search", formLayout, Link.BUTTON_SMALL);
} else if (displayOption.equals(DisplayOption.STANDARD_TEXT)) {
String searchLabel = getTranslator().translate("search");
searchButton = uifactory.addFormLink("search", searchLabel, "", formLayout, Link.NONTRANSLATED + Link.BUTTON_SMALL);
}
searchButton.setEnabled(true);
}
private void loadContext() {
if(resourceUrl != null) {
ContextTokens context = getContextTokens(resourceUrl);
setContext(context);
}
}
protected void setContext(ContextTokens context) {
if(!context.isEmpty()) {
String scope = context.getValueAt(context.getSize() - 1);
String tooltip = getTranslator().translate("form.search.label.tooltip", new String[]{scope});
((Link)searchButton.getComponent()).setTooltip(tooltip, false);
}
}
private void setSearchStore(UserRequest ureq) {
prefs = (Map<String,Properties>)ureq.getUserSession().getEntry(SEARCH_STORE_KEY);
if(prefs == null) {
prefs = new HashMap<String,Properties>();
ureq.getUserSession().putEntry(SEARCH_STORE_KEY, prefs);
}
searchCache = (SearchLRUCache)ureq.getUserSession().getEntry(SEARCH_CACHE_KEY);
if(searchCache == null) {
searchCache = new SearchLRUCache();
ureq.getUserSession().putEntry(SEARCH_CACHE_KEY, searchCache);
}
}
@Override
protected void doDispose() {
//
}
@Override
protected void formOK(UserRequest ureq) {
doSearch(ureq);
}
@Override
protected void formNOK(UserRequest ureq) {
doSearch(ureq);
}
@Override
protected void formInnerEvent(UserRequest ureq, FormItem source, FormEvent event) {
if (source == searchButton) {
doSearch(ureq);
} else if (didYouMeanLinks != null && didYouMeanLinks.contains(source)) {
String didYouMeanWord = (String)source.getUserObject();
searchInput.setValue(didYouMeanWord);
doSearch(ureq, didYouMeanWord, null, parentContext, documentType, resourceUrl, false);
}
}
protected void doSearch(UserRequest ureq) {
if (resultCtlr != null) {
removeAsListenerAndDispose(resultCtlr);
resultCtlr = null;
}
String oldSearchString = null;
Properties props = getPersistedSearch();
if(props != null) {
oldSearchString = props.getProperty("s");
}
persistSearch(ureq);
if (DisplayOption.BUTTON.equals(displayOption) || DisplayOption.BUTTON_WITH_LABEL.equals(displayOption)) {
//no search, only popup
createResultsSearchController(ureq);
popupResultsSearchController(ureq);
if(resultCtlr.getPersistedSearch() != null && !resultCtlr.getPersistedSearch().isEmpty()) {
resultCtlr.doSearch(ureq);
}
} else {
String searchString = getSearchString();
if(StringHelper.containsNonWhitespace(searchString)) {
if(oldSearchString != null && !oldSearchString.equals(searchString)) {
resetSearch();
}
createResultsSearchController(ureq);
resultCtlr.setSearchString(searchString);
popupResultsSearchController(ureq);
resultCtlr.doSearch(ureq);
}
}
}
protected Properties getPersistedSearch() {
if(getResourceUrl() != null) {
String uri = getResourceUrl();
Properties props = prefs.get(uri);
if(props == null) {
props = new Properties();
prefs.put(uri, props);
}
return props;
}
//not possible but i don't want to trigger a red screen for this if i'm wrong
return new Properties();
}
protected void resetSearch() {
if(getResourceUrl() != null) {
String uri = getResourceUrl();
Properties props = prefs.get(uri);
if(props != null) {
prefs.remove(uri);
}
}
}
protected void persistSearch(UserRequest ureq) {
if(getResourceUrl() != null) {
String uri = getResourceUrl();
Properties props = prefs.get(uri);
if(props == null) {
props = new Properties();
}
getSearchProperties(props);
if(props.isEmpty()) {
prefs.remove(uri);
} else {
prefs.put(uri, props);
}
}
}
protected void loadPersistedSearch() {
if(getResourceUrl() != null) {
String uri = getResourceUrl();
Properties props = prefs.get(uri);
if(props != null) {
setSearchProperties(props);
}
}
}
private void createResultsSearchController(UserRequest ureq) {
resultCtlr = new ResultsSearchController(ureq, getWindowControl(), getResourceUrl());
resultCtlr.setDocumentType(getDocumentType());
resultCtlr.setParentContext(getParentContext());
resultCtlr.setResourceContextEnable(isResourceContextEnable());
listenTo(resultCtlr);
}
protected void getSearchProperties(Properties props) {
if (displayOption.equals(DisplayOption.STANDARD) || displayOption.equals(DisplayOption.STANDARD_TEXT)) {
String searchString = getSearchString();
props.setProperty("s", searchString == null ? "" : searchString);
}
}
protected void setSearchProperties(Properties props) {
if (displayOption.equals(DisplayOption.STANDARD) || displayOption.equals(DisplayOption.STANDARD_TEXT)) {
String searchString = props.getProperty("s");
if(StringHelper.containsNonWhitespace(searchString)) {
setSearchString(searchString);
} else {
setSearchString("");
}
}
}
private void popupResultsSearchController(UserRequest ureq) {
String title = translate("search.title");
boolean ajaxOn = getWindowControl().getWindowBackOffice().getWindowManager().isAjaxEnabled();
if (ajaxOn) {
searchDialogBox = new CloseableModalWindowController(ureq, getWindowControl(), title, resultCtlr.getInitialComponent(), "ofulltextsearch");
((CloseableModalWindowController)searchDialogBox).activate();
resultCtlr.listenTo(searchDialogBox);
} else {
searchDialogBox = new CloseableModalController(getWindowControl(), title, resultCtlr.getInitialComponent());
((CloseableModalController)searchDialogBox).activate();
}
}
@Override
protected void event(UserRequest ureq, Controller source, Event event) {
if (source == resultCtlr) {
if (event instanceof SearchEvent) {
SearchEvent goEvent = (SearchEvent)event;
ResultDocument doc = goEvent.getDocument();
gotoSearchResult(ureq, doc);
} else if (event == Event.DONE_EVENT) {
setSearchString(resultCtlr.getSearchString());
}
} else if (CloseableModalWindowController.CLOSE_WINDOW_EVENT.equals(event)) {
fireEvent(ureq, Event.DONE_EVENT);
}
}
public void closeSearchDialogBox() {
if (searchDialogBox instanceof CloseableModalController) {
((CloseableModalController)searchDialogBox).deactivate();
} else if(searchDialogBox instanceof CloseableModalWindowController) {
((CloseableModalWindowController)searchDialogBox).deactivate();
}
searchDialogBox = null;
}
/**
*
* @param ureq
* @param command
*/
public void gotoSearchResult(UserRequest ureq, ResultDocument document) {
try {
// attach the launcher data
closeSearchDialogBox();
String url = document.getResourceUrl();
if(!StringHelper.containsNonWhitespace(url)) {
//no url, no document
getWindowControl().setWarning(getTranslator().translate("error.resource.could.not.found"));
} else if(url != null && url.startsWith("[ContextHelpModule:")) {
//do something special for ContextHelp
int pathIndex = url.indexOf("path=");
String uri = url.substring(pathIndex + 5, url.length() - 1);
RedirectMediaResource rsrc = new RedirectMediaResource(uri);
ureq.getDispatchResult().setResultingMediaResource(rsrc);
} else {
BusinessControl bc = BusinessControlFactory.getInstance().createFromString(url);
WindowControl bwControl = BusinessControlFactory.getInstance().createBusinessWindowControl(bc, getWindowControl());
NewControllerFactory.getInstance().launch(ureq, bwControl);
}
} catch (Exception ex) {
log.debug("Document not found");
getWindowControl().setWarning(getTranslator().translate("error.resource.could.not.found"));
}
}
protected SearchResults doSearch(UserRequest ureq, String searchString, String condSearchString, String parentCtxt, String docType, String rsrcUrl, boolean doSpellCheck) {
String query = null;
try {
if(doSpellCheck) {
//remove first old "did you mean words"
hideDidYouMeanWords();
}
getHighlightWords(searchString);
query = getQueryString(searchString, condSearchString, parentCtxt, docType, rsrcUrl, false);
SearchResults searchResults = searchCache.get(query);
if(searchResults == null) {
searchResults = searchService.doSearch(query, ureq.getIdentity(), ureq.getUserSession().getRoles(), true);
searchCache.put(query, searchResults);
}
if (searchResults.getList().isEmpty() && !query.endsWith(FUZZY_SEARCH)) {
// result-list was empty => first try to find word via spell-checker
if (doSpellCheck) {
Set<String> didYouMeansWords = searchService.spellCheck(query);
if (didYouMeansWords != null && !didYouMeansWords.isEmpty()) {
setDidYouMeanWords(didYouMeansWords);
} else {
searchResults = doFuzzySearch(ureq, searchString, condSearchString, parentCtxt, docType, rsrcUrl);
}
} else {
searchResults = doFuzzySearch(ureq, searchString, condSearchString, parentCtxt, docType, rsrcUrl);
}
}
if(searchResults.getList().isEmpty()) {
showInfo("found.no.result.try.fuzzy.search");
}
return searchResults;
} catch (ParseException e) {
if(log.isDebug()) log.debug("Query cannot be parsed: " + query);
getWindowControl().setWarning(translate("invalid.search.query"));
} catch (QueryException e) {
getWindowControl().setWarning(translate("invalid.search.query.with.wildcard"));
} catch(ServiceNotAvailableException e) {
getWindowControl().setWarning(translate("search.service.not.available"));
} catch (Exception e) {
log.error("", e);
getWindowControl().setWarning(translate("search.service.not.available"));
}
return SearchResults.EMPTY_SEARCH_RESULTS;
}
protected Set<String> getHighlightWords(String searchString) {
try {
Analyzer analyzer = new StandardAnalyzer(Version.LUCENE_CURRENT);
TokenStream stream = analyzer.tokenStream("content", new StringReader(searchString));
TermAttribute termAtt = (TermAttribute) stream.addAttribute(TermAttribute.class);
for (boolean next = stream.incrementToken(); next; next = stream.incrementToken()) {
String term = termAtt.term();
if(log.isDebug()) log.debug(term);
}
} catch (IOException e) {
log.error("", e);
}
return null;
}
protected SearchResults doFuzzySearch(UserRequest ureq, String searchString, String condSearchString, String parentCtxt, String docType, String rsrcUrl)
throws QueryException, ParseException, ServiceNotAvailableException {
hideDidYouMeanWords();
String query = getQueryString(searchString, condSearchString, parentCtxt, docType, rsrcUrl, true);
SearchResults searchResults = searchCache.get(query);
if(searchResults == null) {
searchResults = searchService.doSearch(query, ureq.getIdentity(), ureq.getUserSession().getRoles(), true);
searchCache.put(query, searchResults);
}
return searchResults;
}
public Set<String> getDidYouMeanWords() {
if (didYouMeanLinks != null && !didYouMeanLinks.isEmpty()) {
Set<String> didYouMeanWords = new HashSet<String>();
for(FormLink link:didYouMeanLinks) {
String word = (String)link.getUserObject();
didYouMeanWords.add(word);
}
return didYouMeanWords;
}
return Collections.emptySet();
}
/**
* Unregister existing did-you-mean-links from content and add new links.
* @param didYouMeansWords List of 'did you mean' words
*/
public void setDidYouMeanWords(Set<String> didYouMeansWords) {
// unregister existing did-you-mean links
hideDidYouMeanWords();
didYouMeanLinks = new ArrayList<FormLink>(didYouMeansWords.size());
int wordNumber = 0;
for (String word : didYouMeansWords) {
FormLink l = uifactory.addFormLink(CMD_DID_YOU_MEAN_LINK + wordNumber++, word, null, flc, Link.NONTRANSLATED);
l.setUserObject(word);
didYouMeanLinks.add(l);
}
flc.contextPut("didYouMeanLinks", didYouMeanLinks);
flc.contextPut("hasDidYouMean", Boolean.TRUE);
}
protected void hideDidYouMeanWords() {
// unregister existing did-you-mean links
if (didYouMeanLinks != null) {
for (int i = 0; i < didYouMeanLinks.size(); i++) {
flc.remove(CMD_DID_YOU_MEAN_LINK + i);
}
didYouMeanLinks = null;
}
flc.contextPut("didYouMeanLinks", didYouMeanLinks);
flc.contextPut("hasDidYouMean", Boolean.FALSE);
}
private String getQueryString(String searchString, String condSearchString, String parentCtxt, String docType, String rsrcUrl, boolean fuzzy) {
StringBuilder query = new StringBuilder(searchString);
if(fuzzy) {
query.append(FUZZY_SEARCH);
}
if (StringHelper.containsNonWhitespace(condSearchString)) {
appendAnd(query, condSearchString);
}
if (StringHelper.containsNonWhitespace(parentCtxt)) {
appendAnd(query, OlatDocument.PARENT_CONTEXT_TYPE_FIELD_NAME, ":\"", parentCtxt, "\"");
}
if (StringHelper.containsNonWhitespace(docType)) {
appendAnd(query, OlatDocument.DOCUMENTTYPE_FIELD_NAME, ":\"", docType, "\"");
}
if (StringHelper.containsNonWhitespace(rsrcUrl)) {
appendAnd(query, OlatDocument.RESOURCEURL_FIELD_NAME, ":", escapeResourceUrl(rsrcUrl), "*");
}
return query.toString();
}
private void appendAnd(StringBuilder query, String... strings) {
if(query.length() > 0) {
query.append(" AND ");
}
for(String string:strings) {
query.append(string);
}
}
/**
* Remove the ROOT keyword, duplicate entry in the business path
* and escape the keywords used by lucene.
* @param url
* @return
*/
protected String escapeResourceUrl(String url) {
List<String> tokens = getResourceUrlTokenized(url);
StringBuilder sb = new StringBuilder();
for(String token:tokens) {
sb.append("\\[").append(token.replace(":", "\\:")).append("\\]");
}
return sb.toString();
}
protected List<String> getResourceUrlTokenized(String url) {
if (url.startsWith("ROOT")) {
url = url.substring(4, url.length());
}
List<String> tokens = new ArrayList<String>();
for(StringTokenizer tokenizer = new StringTokenizer(url, "[]"); tokenizer.hasMoreTokens(); ) {
String token = tokenizer.nextToken();
if(!tokens.contains(token)) {
tokens.add(token);
}
}
return tokens;
}
protected ContextTokens getContextTokens(String resourceURL) {
SearchServiceUIFactory searchUIFactory = (SearchServiceUIFactory)ServiceFactory.getService(SearchServiceUIFactory.class);
List<String> tokens = getResourceUrlTokenized(resourceURL);
String[] keys = new String[tokens.size() + 1];
String[] values = new String[tokens.size() + 1];
keys[0] = "";
values[0] = translate("search.context.all");
StringBuilder sb = new StringBuilder();
for(int i=0; i<tokens.size(); i++) {
String token = tokens.get(i);
keys[i+1] = sb.append('[').append(token).append(']').toString();
values[i+1] = searchUIFactory.getBusinessPathLabel(token, tokens, getLocale());
}
return new ContextTokens(keys, values);
}
public FormItem getFormItem() {
return flc;
}
public class ContextTokens {
private final String[] keys;
private final String[] values;
public ContextTokens(String[] keys, String[] values) {
this.keys = keys == null ? new String[0] : keys;
this.values = values == null ? new String[0] : values;
}
public String[] getKeys() {
return keys;
}
public String[] getValues() {
return values;
}
public boolean isEmpty() {
return values.length == 0;
}
public int getSize() {
return values.length;
}
public String getKeyAt(int index) {
if(keys != null && index < keys.length && index >= 0) {
return keys[index];
}
return "";
}
public String getValueAt(int index) {
if(values != null && index < values.length && index >= 0) {
return values[index];
}
return "";
}
}
}