BrowseController.java

package info.textgrid.rep.browse;

import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.math.BigInteger;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.TreeMap;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.cxf.helpers.IOUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import info.textgrid.clients.tgcrud.CrudClientException;
import info.textgrid.namespaces.middleware.tgsearch.ResultType;
import info.textgrid.namespaces.middleware.tgsearch.Revisions;
import info.textgrid.rep.i18n.I18N;
import info.textgrid.rep.i18n.I18NProvider;
import info.textgrid.rep.markdown.MarkdownRenderService;
import info.textgrid.rep.service.AggregatorClientService;
import info.textgrid.rep.service.IIIFClientService;
import info.textgrid.rep.service.ServiceConnectionException;
import info.textgrid.rep.service.TgcrudClientService;
import info.textgrid.rep.service.TgrepConfigurationService;
import info.textgrid.rep.service.TgsearchClientService;
import info.textgrid.rep.shared.Utils;
import info.textgrid.rep.shared.ToolLink;

@Controller
public class BrowseController {

  private TgrepConfigurationService tgrepConfig;
  private AggregatorClientService aggregatorClient;
  private TgcrudClientService tgcrudClient;
  private TgsearchClientService tgsearchClient;
  private MarkdownRenderService markdownRenderService;
  private I18NProvider i18nProvider;
  private IIIFClientService iiifClientService;

  private static final Log log = LogFactory.getLog(BrowseController.class);

  @Autowired
  public BrowseController(
      TgrepConfigurationService tgrepConfig,
      AggregatorClientService aggregatorClient,
      TgcrudClientService tgcrudClient,
      TgsearchClientService tgsearchClient,
      MarkdownRenderService markdownRenderService,
      IIIFClientService iiifClientService,
      I18NProvider i18nProvider) {

    this.tgrepConfig = tgrepConfig;
    this.aggregatorClient = aggregatorClient;
    this.tgcrudClient = tgcrudClient;
    this.tgsearchClient = tgsearchClient;
    this.iiifClientService = iiifClientService;
    this.markdownRenderService = markdownRenderService;
    this.i18nProvider = i18nProvider;

  }

  @GetMapping("/browse/{id}")
  public String browse(
      Locale locale,
      Model model,
      @PathVariable("id") String id,
      @RequestParam(value = "mode", defaultValue = "list") String mode,
      @RequestParam(value = "fragment", required = false) String fragment) {

    boolean sandbox = this.tgrepConfig.getSandboxEnabled();

    I18N i18n = i18nProvider.getI18N(locale);

    // common variables for browse-root aggregations and browse single items
    model.addAttribute("mode", mode);

    // if no id, browse all root aggregations
    if (id == null || id.equals("root")) {
      List<ToolLink> viewmodes = new ArrayList<ToolLink>();
      viewmodes
          .add(new ToolLink(i18n.get("list"), "/browse/root?mode=list", mode.equals("list")));
      viewmodes.add(new ToolLink(i18n.get("gallery"), "/browse/root?mode=gallery",
          mode.equals("gallery")));
      model.addAttribute("viewmodes", viewmodes);

      List<ResultType> results = this.tgsearchClient.listRootCollections().getResult();
      model.addAttribute("results", results);
      model.addAttribute("browseRootAggregations", true);
      return "browse";
    }

    if (!id.startsWith("textgrid:")) {
      id = "textgrid:" + id;
    }
    id = id.replace("_", ".");

    model.addAttribute("textgridUri", id);

    ResultType metadata = this.tgsearchClient.getMetadata(id);
    model.addAttribute("metadata", metadata);

    // for path, todo: create a tagfile for path
    model.addAttribute("result", metadata);
    model.addAttribute("projectmap", tgsearchClient.getProjectMap(sandbox));

    String format = metadata.getObject().getGeneric().getProvided().getFormat();
    model.addAttribute("format", format);
    /*
     * Deliver different browse pages based on content type of textgridobject:
     * aggregation, xml or image
     */
    try {
      if (format.contains("tg.aggregation") || format.contains("text/tg.work+xml")) {
         handleAggregations(model, i18n, id, format, mode);
      } else if (format.equals("text/xml") || format.startsWith("application/xml")) {
         handleXml(model, i18n, id, metadata, fragment);
      } else if (format.contains("image")) {
        handleImages(model, id);
      } else if (format.equals("text/markdown")) {
        handleMarkdown(model, id);
      } else if (format.equals("text/plain")) {
        handlePlaintext(model, id);
      }
    } catch(ServiceConnectionException e) {
      // aggregator did not respond in time
      model.addAttribute("isRenderingSlow", true);
      model.addAttribute("renderingMessage", e.getMessage());
      model.addAttribute("renderingTimeout", tgrepConfig.getAggregatorReadTimeout());
      model.addAttribute("isTEI", true);
      model.addAttribute("tools", addTeiTools(id));
    }
    // add revisions info
    listRevisions(model, id, metadata);

    return "browse";
  }

  /**
   * setup browse page to show tools metadata and the like for xml type files, which are now METS/XML or TEI
   *
   * @param model
   * @param i18n
   * @param id
   * @param metadata
   * @param fragment
   * @throws ServiceConnectionException
   */
  private void handleXml(Model model, I18N i18n, String id, ResultType metadata, String fragment) throws ServiceConnectionException {

    List<ToolLink> tools = new ArrayList<ToolLink>();

    // identifier@type of trier dfg-viewer mets is METSXMLID
    // TODO: look into relation/tg:rootElementNamespace
    if (!metadata.getObject().getGeneric().getProvided().getIdentifier().isEmpty()
        && metadata.getObject().getGeneric().getProvided().getIdentifier().get(0) != null
        && metadata.getObject().getGeneric().getProvided().getIdentifier().get(0)
            .getType() != null
        && metadata.getObject().getGeneric().getProvided().getIdentifier().get(0).getType()
            .equals("METSXMLID")) {
      try {
        String tgcrudUrl4DFGViewer = URLEncoder.encode(
            tgrepConfig.getTextgridHost() + "/1.0/tgcrud-public/rest/" + id + "/data", "UTF-8");
        tools.add(new ToolLink("DFG-Viewer",
            "http://dfg-viewer.de/v3/?set[zoom]=min&set[mets]=" + tgcrudUrl4DFGViewer, false));
      } catch (UnsupportedEncodingException e) {
        log.error("error encoding url", e);
      }
    } else { // assume tei-xml

      String teiHtml = "";
      if (fragment != null) {
        teiHtml = this.aggregatorClient.renderTEIFragment(id, fragment);
      } else {
        String xsltUri = this.tgsearchClient.getProjectXsltUri(metadata);
        log.debug("xslt for project is: " + xsltUri);
        model.addAttribute("projectXsltUri", xsltUri);
        teiHtml = this.aggregatorClient.renderTEI(id, xsltUri);
        // TODO https://gitlab.gwdg.de/dariah-de/dariah-de-common/-/issues/39 - for the meantime toc only in digibib
        if(metadata.getObject().getGeneric().getGenerated().getProject().getId().equals("TGPR-372fe6dc-57f2-6cd4-01b5-2c4bbefcfd3c")){
          String tocHtml = this.aggregatorClient.renderToc(id);
          log.debug("toc: " + tocHtml);
          model.addAttribute("tocHtml", tocHtml);
        }
      }
      model.addAttribute("teiHtml", teiHtml);
      model.addAttribute("isTEI", true);
      // TEI specific tools here
      tools.addAll(addTeiTools(id));
    }

    // Mirador link may be shown for METS/MODS or TEI files (if manifest available for this file)
    if (hasIiifManifest(metadata.getObject().getGeneric().getGenerated().getProject().getId())) {
      String manifestUrl = tgrepConfig.getTextgridHost() + "/1.0/iiif/manifests/" + id + "/manifest.json";
      //tools.add(new ToolLink("Mirador", tgrepConfig.getToolMiradorHost() + "/?uri=" + id, false));
      tools.add(new ToolLink("Mirador", "https://projectmirador.org/embed/?iiif-content=" + manifestUrl, false));
      //tools.add(new ToolLink("Universal Viewer", "https://uv-v3.netlify.app/#?c=&m=&s=&cv=&manifest=" + manifestUrl, false));
      tools.add(new ToolLink(
          "<img style='margin-top:2px; height: 21px;' title='Drop icon on Mirador to open manifest' src='" + tgrepConfig.getTextgridHost() + "/1.0/iiif/manifests/img/iiif-logo.svg'>",
          manifestUrl, false
      ));
    }

    model.addAttribute("tools", tools);
  }

  /**
   * Some tools only useful for browsing TEI (voyant, switchboard)
   * @param tools
   * @param id
   */
  private List<ToolLink> addTeiTools(String id) {
    List<ToolLink> tools = new ArrayList<ToolLink>();
    // Voyant
    tools.add(
        new ToolLink("Voyant", tgrepConfig.getVoyantHost() + "/?input=" + tgrepConfig.getTextgridHost() + "/1.0/tgcrud-public/rest/" + id + "/data", false)
          .setHelpLink("/docs/voyant")
    );
    // Annotate
    tools.add(
        new ToolLink("Annotate", tgrepConfig.getToolAnnotateHost() + "/data.html?uri=" + id, false, "annotation-button")
          .setHelpLink("/docs/annotate")
    );
    // CLARIN Language Resource Switchboard (LRS)
    try {
      //String xml4switchboard = URLEncoder.encode(tgrepConfig.getTextgridHost() + "/1.0/tgcrud-public/rest/" + id + "/data", "UTF-8");
      String text4switchboard = URLEncoder.encode(tgrepConfig.getTextgridHost() + "/1.0/aggregator/text/" + id , "UTF-8");
      tools.add(
          new ToolLink("Switchboard", tgrepConfig.getToolSwitchboardHost() + "/" + text4switchboard + "/text%2Fplain", false)
            .setHelpLink("/docs/switchboard")
      );
    } catch (UnsupportedEncodingException e) {
      log.error("error encoding url for switchboard", e);
    }
    return tools;
  }

  /**
   * setup browsing of aggregations
   *
   * @param model
   * @param i18n
   * @param id
   * @param format
   * @param mode
   * @throws ServiceConnectionException
   */
  private void handleAggregations(Model model, I18N i18n, String id, String format, String mode) throws ServiceConnectionException {
    List<ToolLink> viewmodes = new ArrayList<ToolLink>();

    viewmodes
        .add(new ToolLink(i18n.get("list"), Utils.browseUrl(id) + "?mode=list", mode.equals("list")));
    viewmodes.add(new ToolLink(i18n.get("gallery"), Utils.browseUrl(id) + "?mode=gallery",
        mode.equals("gallery")));

    if (format.equals("text/tg.edition+tg.aggregation+xml")) {
      viewmodes.add(new ToolLink("TEI-Corpus", Utils.browseUrl(id) + "?mode=xml", mode.equals("xml")));
    }

    model.addAttribute("viewmodes", viewmodes);

    if (mode != null && mode.equals("xml")) {
      String teiHtml = this.aggregatorClient.renderTEI(id);
      String tocHtml = this.aggregatorClient.renderToc(id);
      model.addAttribute("teiHtml", teiHtml);
      model.addAttribute("tocHtml", tocHtml);

    } else {
      log.debug("listing aggregation: " + id);
      List<ResultType> results;

      if (format.contains("text/tg.work+xml")) {
        String workUri = id.contains(".") ? id : id + "." + tgsearchClient.latestRevision(id);;
        results = this.tgsearchClient.getSearchClient()
            .searchQuery()
            .setQuery("*")
            .addFilter("edition.isEditionOf:"+workUri)
            .addFilter("format:text/tg.edition+tg.aggregation+xml")
            .setSearchSandbox(this.tgrepConfig.getSandboxEnabled())
            .execute()
            .getResult();
      } else {
        results = this.tgsearchClient.listAggregation(id).getResult();
      }
      model.addAttribute("results", results);
    }
  }

  /**
   * browsing images, digilib link in tools
   * @param model
   * @param id
   */
  private void handleImages(Model model, String id) {
    model.addAttribute("image", true);
    List<ToolLink> tools = new ArrayList<ToolLink>();
    tools.add(new ToolLink("Digilib", tgrepConfig.getToolDigilibHost() + "/digilib.html?fn=" + id, false));
    model.addAttribute("tools", tools);
  }

  private void handleMarkdown(Model model, String id) {
    InputStream contentStream;
    String content;
    try {
      contentStream = this.tgcrudClient.read(id);
      content = markdownRenderService.renderHtml(contentStream);
    } catch (CrudClientException e) {
      content = "error reading data from crud";
      log.error(content, e);
    } catch (IOException e) {
      content = "error parsing markdown";
      log.error(content, e);
    }

    model.addAttribute("htmlContent", content);
  }

  private void handlePlaintext(Model model, String id) {
    InputStream contentStream;
    String content;
    try {
      contentStream = this.tgcrudClient.read(id);
      content = IOUtils.toString(contentStream, StandardCharsets.UTF_8.name());
    } catch (CrudClientException | IOException e) {
      content = "error reading data";
      log.error(content, e);
    }

    model.addAttribute("textContent", content);
  }

  private void listRevisions(Model model, String id, ResultType metadata) {
    String baseUri = id.contains(".") ? id.substring(0,  id.indexOf(".")) : id;
    Revisions revisions = tgsearchClient.listRevisions(id);
    int displayRev = metadata.getObject().getGeneric().getGenerated().getRevision();
    int latestRev = Collections.max(revisions.getRevision()).intValue();
    if(displayRev < latestRev) {
      model.addAttribute("higherRevisionAvailable", true);
      model.addAttribute("latestRevision", latestRev);
      model.addAttribute("latestRevisionUri", baseUri+"."+latestRev);
    }

    if(revisions.getRevision().size() > 0) {
      TreeMap<Integer, String> revmap = new TreeMap<Integer, String>(Comparator.reverseOrder());
      for(BigInteger rev : revisions.getRevision()) {
        revmap.put(rev.intValue(), baseUri + "." + rev);
      }
      model.addAttribute("revisions", revmap);
    }
  }

  /**
   * ask the iiif service if a project is
   * configured to generate iiif manifests for
   *
   * moving this method to iiifClientService would bypass the spring cache
   * so we keep it here (https://www.baeldung.com/spring-invoke-cacheable-other-method-same-bean)
   *
   * @param id the projectId
   * @return true if iiif manifests are generated for this project
   */
  private boolean hasIiifManifest(String id) {
    if (iiifClientService.getManifests().contains(id)) {
      return true;
    }
    return false;
  }

}