SearchController.java

package info.textgrid.rep.search;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.AbstractMap.SimpleEntry;
import java.util.Map.Entry;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.propertyeditors.CustomCollectionEditor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.SessionAttributes;
import org.springframework.web.util.HtmlUtils;
import info.textgrid.namespaces.metadata.portalconfig._2020_06_16.Portalconfig;
import info.textgrid.namespaces.middleware.tgsearch.Response;
import info.textgrid.rep.botprotect.ProofOfWorkSession;
import info.textgrid.rep.i18n.I18N;
import info.textgrid.rep.i18n.I18NProvider;
import info.textgrid.rep.service.TgrepConfigurationService;
import info.textgrid.rep.service.TgsearchClientService;
import info.textgrid.rep.shared.Pager;
import info.textgrid.rep.shared.ToolLink;
import info.textgrid.rep.shared.Utils;

@Controller
@SessionAttributes("proofOfWorkSession")
public class SearchController {

  private TgsearchClientService tgsearchClient;
  private TgrepConfigurationService tgrepConfig;
  private I18NProvider i18nProvider;

  private static final Logger log = LoggerFactory.getLogger(SearchController.class);

  private List<String> defaultFacets = Arrays.asList(new String[] {
      "edition.language",
      "edition.agents.author.value",
      "work.genre",
      "format",
      "project.id"
  });

  @Value("${tgsearch.query.maxhits}")
  private int maxhits;

  @ModelAttribute("proofOfWorkSession")
  public ProofOfWorkSession getProofOfWorkSession() {
      return new ProofOfWorkSession();
  }

  @Autowired
  public SearchController(
      TgsearchClientService tgsearchClient,
      TgrepConfigurationService tgrepConfig,
      I18NProvider i18nProvider) {
    this.tgsearchClient = tgsearchClient;
    this.tgrepConfig = tgrepConfig;
    this.i18nProvider = i18nProvider;
  }

  /**
   * without this method the filter param passed to search could be separated at
   * a comma, for example "filter:Tucholsky, Kurt" would become
   *    ["filter:Tucholsy", "Kurt"] but we want ["filter:Tucholsy, Kurt"]
   * @param binder
   */
  @InitBinder
  public void initBinder(WebDataBinder binder) {
    binder.registerCustomEditor(List.class, new CustomCollectionEditor(List.class));
  }

  @GetMapping({"/search", "/search/"})
  public String search(
      @RequestParam(name="query", required=false, defaultValue="") String query,
      @RequestParam(name="order", required=false, defaultValue="relevance") String order,
      @RequestParam(name="start", required=false, defaultValue="0") int start,
      @RequestParam(name="limit", required=false, defaultValue="20") int limit,
      @RequestParam(name="filter", required=false) List<String> filter,
      @RequestParam(value="mode", defaultValue="list") String mode,
      @ModelAttribute ProofOfWorkSession proofOfWorkSession,
      Locale locale,
      Model model) {

    model.addAttribute("query", HtmlUtils.htmlEscape(query));
    model.addAttribute("order", order);
    model.addAttribute("start", start);
    model.addAttribute("limit", limit);
    model.addAttribute("filter", filter);

    // detect wildcard or fuzzy search, which is not "searchAll" wildcard (* with length > 1)
    if(
        (query.contains("*") && query.length() > 1)
        || (query.contains("%2A") && query.length() > 3)
        || query.contains("?")
        || query.contains("%3F")
        || query.contains("~")
      ){
      // proofOfWorkSession.verfified will be set by AltchaController
      if(!proofOfWorkSession.isVerified()) {
        log.info("unverified wildcard search detected:" + query);
        return "checkRobot";
      } else {
        log.info("wildcard search permitted: " + query);
      }
    }

    if (! mode.equals("list") &! mode.equals("gallery")) {
      mode = "list";
    }

    I18N i18n = i18nProvider.getI18N(locale);

    boolean sandbox = this.tgrepConfig.getSandboxEnabled();
    String aggregatorSandboxParam = sandbox ? "&sandbox=true" : "";

    String realQueryString = query;
    if(query.equals("") && filter != null) {
        // the filter only request, e.g. from facet-browse
        realQueryString = "*";
    }

    List<String> facets = null;

    // is a project filter active? If yes, check for project specific facets
    if(filter != null && filter.stream().anyMatch(a -> a.startsWith("project.id"))) {
      String projectId = filter.stream().filter(a -> a.startsWith("project.id")).collect(Collectors.toList()).get(0).substring(11);
      facets = new ArrayList<String>();
      for (Entry<String, String> pfacet : getProjectFacets(projectId, locale, i18n).entrySet()) {
        facets.add(pfacet.getKey());
        // facet label to translation map
        i18n.getTranslationMap().put(pfacet.getKey(), pfacet.getValue());
      }
      facets.addAll(defaultFacets);
    } else {
      facets = defaultFacets;
    }

    Response res = this.tgsearchClient.search(realQueryString, order, start, limit, facets, filter, sandbox);

    if(res == null) {
      return "error";
    }

    // TODO: set hitlimit (maxhits?) here or inside page (make it a spring bean?)
    Pager pager = new Pager()
        .setHits(Integer.parseInt(res.getHits()))
        .setLimit(limit)
        .setStart(start)
        .setMaxHits(maxhits);

    pager.calculatePages();

    model.addAttribute("pager", pager);

    List<ToolLink> viewmodes = new ArrayList<ToolLink>();
    viewmodes.add(new ToolLink(i18n.get("list"), Utils.searchUrl("list", query, filter, order, start, limit), mode.equals("list")));
    viewmodes.add(new ToolLink(i18n.get("gallery"), Utils.searchUrl("gallery", query, filter, order, start, limit), mode.equals("gallery")));
    model.addAttribute("viewmodes", viewmodes);
    model.addAttribute("mode", mode);
    model.addAttribute("results", res.getResult());
    model.addAttribute("facetResponse", res.getFacetResponse());
    model.addAttribute("projectmap", tgsearchClient.getProjectMap(sandbox));
    model.addAttribute("filterQueryString", Utils.getFilterQueryString(filter));
    model.addAttribute("aggregatorSandboxParam", aggregatorSandboxParam);
    model.addAttribute("realQueryString", realQueryString);

    // which .jsp to render
    return "search";

  }

  /**
   * get project specific facets config from portalconfig if available for given projectId
   *
   * @param projectId   the projectId
   * @param locale      the locale for facet label selection
   * @param i18n        translation array, for adding labels of project specific facets
   * @return            facets and their labels for the locale, or empty hashmap if nothing found
   */
  private Map<String, String> getProjectFacets(String projectId, Locale locale, I18N i18n) {

    // try to find project config in tgsearch
    Portalconfig projectConfig = this.tgsearchClient.getProjectConfig(projectId, false).getPortalconfig();

    // return empty hashmap if nothing found
    if (projectConfig == null || projectConfig.getFacets() == null || projectConfig.getFacets().getFacet() == null) {
      return new HashMap<String, String>();
    }

    // portalconfig file contains XML with JAXB databinding, we want to only get the facet config, and
    // map the select elements to their given title in a new hashmap
    Map<String, String> facetConf = projectConfig.getFacets().getFacet().stream()
        .collect(
            Collectors.toMap(
                a -> a.getSelect(),
                a -> a.getTitle().stream()
                  // get title for lang code (no fallback yet if no value for lang in projectconf)
                  .filter(b -> b.getLang().equals(locale.getLanguage()))
                  .map(b -> b.getValue())
                  .collect(Collectors.toList()).get(0)
            )
        );

    Map<String, String> labels = getProjectFacetLabels(projectConfig, locale.getLanguage());
    i18n.getTranslationMap().putAll(labels);

    return facetConf;
  }

  /**
   * get a hashmap of pdf_ (project defined facets) prefixed i18n keys and labels for chosen
   * language from given projectconfig
   *
   * @param projectConfig the projectconfig
   * @param lang          language to select labels for
   * @return              hashmap of id's (pdf_ prefixed) and labels for i18n translationmap
   */
  private Map<String, String> getProjectFacetLabels(Portalconfig projectConfig, String lang) {

    Map<String, String> labels = projectConfig.getFacets().getFacet().stream()
        .flatMap(a -> a.getLabel().stream().filter(b -> b.getLang().equals(lang))
            .flatMap(v -> Stream.of(new SimpleEntry<String, String>("pdf_" + v.getFor(), v.getValue()))))
        .collect(Collectors.toMap(Entry::getKey, Entry::getValue));

    return labels;
  }


}