Source: src/js/views/MetadataView.js

define([
  "jquery",
  "jqueryui",
  "underscore",
  "backbone",
  "gmaps",
  "fancybox",
  "clipboard",
  "collections/DataPackage",
  "models/DataONEObject",
  "models/PackageModel",
  "models/SolrResult",
  "models/metadata/ScienceMetadata",
  "models/MetricsModel",
  "common/Utilities",
  "views/DataPackageView",
  "views/DownloadButtonView",
  "views/ProvChartView",
  "views/MetadataIndexView",
  "views/ExpandCollapseListView",
  "views/ProvStatementView",
  "views/CitationHeaderView",
  "views/citations/CitationModalView",
  "views/AnnotationView",
  "views/MarkdownView",
  "text!templates/metadata/metadata.html",
  "text!templates/dataSource.html",
  "text!templates/publishDOI.html",
  "text!templates/newerVersion.html",
  "text!templates/loading.html",
  "text!templates/metadataControls.html",
  "text!templates/metadataInfoIcons.html",
  "text!templates/alert.html",
  "text!templates/editMetadata.html",
  "text!templates/dataDisplay.html",
  "text!templates/map.html",
  "text!templates/annotation.html",
  "text!templates/metaTagsHighwirePress.html",
  "uuid",
  "views/MetricView",
], (
  $,
  $ui,
  _,
  Backbone,
  gmaps,
  fancybox,
  Clipboard,
  DataPackage,
  DataONEObject,
  Package,
  SolrResult,
  ScienceMetadata,
  MetricsModel,
  Utilities,
  DataPackageView,
  DownloadButtonView,
  ProvChart,
  MetadataIndex,
  ExpandCollapseList,
  ProvStatement,
  CitationHeaderView,
  CitationModalView,
  AnnotationView,
  MarkdownView,
  MetadataTemplate,
  DataSourceTemplate,
  PublishDoiTemplate,
  VersionTemplate,
  LoadingTemplate,
  ControlsTemplate,
  MetadataInfoIconsTemplate,
  AlertTemplate,
  EditMetadataTemplate,
  DataDisplayTemplate,
  MapTemplate,
  AnnotationTemplate,
  metaTagsHighwirePressTemplate,
  uuid,
  MetricView,
) => {
  "use strict";

  /**
   * @class MetadataView
   * @classdesc A human-readable view of a science metadata file
   * @classcategory Views
   * @augments Backbone.View
   * @class
   * @screenshot views/MetadataView.png
   */
  const MetadataView = Backbone.View.extend(
    /** @lends MetadataView.prototype */ {
      subviews: [],

      pid: null,
      seriesId: null,
      saveProvPending: false,

      model: new SolrResult(),
      packageModels: new Array(),
      entities: new Array(),
      dataPackage: null,
      dataPackageSynced: false,
      el: "#Content",
      metadataContainer: "#metadata-container",
      citationContainer: "#citation-container",
      tableContainer: "#table-container",
      controlsContainer: "#metadata-controls-container",
      metricsContainer: "#metrics-controls-container",
      editorControlsContainer: "#editor-controls-container",
      breadcrumbContainer: "#breadcrumb-container",
      parentLinkContainer: "#parent-link-container",
      dataSourceContainer: "#data-source-container",
      articleContainer: "#article-container",

      type: "Metadata",

      // Templates
      template: _.template(MetadataTemplate),
      alertTemplate: _.template(AlertTemplate),
      doiTemplate: _.template(PublishDoiTemplate),
      versionTemplate: _.template(VersionTemplate),
      loadingTemplate: _.template(LoadingTemplate),
      controlsTemplate: _.template(ControlsTemplate),
      infoIconsTemplate: _.template(MetadataInfoIconsTemplate),
      dataSourceTemplate: _.template(DataSourceTemplate),
      editMetadataTemplate: _.template(EditMetadataTemplate),
      dataDisplayTemplate: _.template(DataDisplayTemplate),
      mapTemplate: _.template(MapTemplate),
      metaTagsHighwirePressTemplate: _.template(metaTagsHighwirePressTemplate),

      objectIds: [],

      /**
       * Text to display in the help tooltip for the alternative identifier field,
       * if the field is present.
       * @type {string}
       * @since 2.26.0
       */
      alternativeIdentifierHelpText: `
         An identifier used to reference this dataset in the past or in another
         system. This could be a link to the original dataset or an old
         identifier that was replaced. The referenced dataset may be the same
         or different from the one you are currently viewing, and its
         accessibility may vary. It may provide additional context about the
         history and evolution of the dataset.
        `,

      // Delegated events for creating new items, and clearing completed ones.
      events: {
        "click #publish": "publish",
        "mouseover .highlight-node": "highlightNode",
        "mouseout  .highlight-node": "highlightNode",
        "click     .preview": "previewData",
        "click     #save-metadata-prov": "saveProv",
      },

      initialize(options) {
        if (options === undefined || !options) var options = {};

        this.pid =
          options.pid || options.id || MetacatUI.appModel.get("pid") || null;

        this.dataPackage = null;

        if (typeof options.el !== "undefined") this.setElement(options.el);
      },

      // Render the main metadata view
      render() {
        this.stopListening();

        MetacatUI.appModel.set("headerType", "default");
        //  this.showLoading("Loading...");

        // Reset various properties of this view first
        this.classMap = new Array();
        this.subviews = new Array();
        this.model.set(this.model.defaults);
        this.packageModels = new Array();

        // get the pid to render
        if (!this.pid) this.pid = MetacatUI.appModel.get("pid");

        this.listenTo(MetacatUI.appUserModel, "change:loggedIn", this.render);

        // Listen to when the metadata has been rendered
        this.once("metadataLoaded", function () {
          this.createAnnotationViews();
          this.insertMarkdownViews();
        });

        // Listen to when the package table has been rendered
        this.once("dataPackageRendered", function () {
          const packageTableContainer = this.$("#data-package-container");
          $(packageTableContainer).children(".loading").remove();

          // Scroll to the element on the page that is in the hash fragment (if there is one)
          this.scrollToFragment();
        });

        this.getModel();

        return this;
      },

      /**
       * Retrieve the resource map given its PID, and when it's fetched,
       * check for write permissions, then check for private members in the package
       * table view, if there is one.
       * @param {string} pid - The PID of the resource map
       */
      getDataPackage(pid) {
        // Create a DataONEObject model to use in the DataPackage collection.
        const dataOneObject = new ScienceMetadata({ id: this.model.get("id") });

        const view = this;

        // Create a new data package with this id
        this.dataPackage = new DataPackage([dataOneObject], { id: pid });

        this.dataPackage.mergeModels([this.model]);

        // If there is no resource map
        if (!pid) {
          // mark the data package as synced,
          // since there are no other models to fetch
          this.dataPackageSynced = true;
          this.trigger("changed:dataPackageSynced");
          this.checkWritePermissions();
          return;
        }

        this.listenToOnce(this.dataPackage, "complete", function () {
          this.dataPackageSynced = true;
          this.trigger("changed:dataPackageSynced");
          const dataPackageView = _.findWhere(this.subviews, {
            type: "DataPackage",
          });
          if (dataPackageView) {
            dataPackageView.dataPackageCollection = this.dataPackage;
            dataPackageView.checkForPrivateMembers();
          }
        });

        this.listenToOnce(this.dataPackage, "fetchFailed", () => {
          view.dataPackageSynced = false;

          // stop listening to the fetch complete
          view.stopListening(view.dataPackage, "complete");

          // Remove the loading elements
          view.$(view.tableContainer).find(".loading").remove();

          // Show an error message
          MetacatUI.appView.showAlert(
            "Error retrieving files for this data package.",
            "alert-error",
            view.$(view.tableContainer),
          );
        });

        if (
          this.dataPackage.packageModel &&
          this.dataPackage.packageModel.get("synced") === true
        ) {
          this.checkWritePermissions();
        } else {
          this.listenToOnce(this.dataPackage.packageModel, "sync", function () {
            this.checkWritePermissions();
          });
        }
        // Fetch the data package. DataPackage.parse() triggers 'complete'
        this.dataPackage.fetch({
          fetchModels: false,
        });
      },

      /*
       * Retrieves information from the index about this object, given the id (passed from the URL)
       * When the object info is retrieved from the index, we set up models depending on the type of object this is
       */
      getModel(pid) {
        // Get the pid and sid
        if (typeof pid === "undefined" || !pid) var { pid } = this;
        if (typeof this.seriesId !== "undefined" && this.seriesId)
          var sid = this.seriesId;

        // Get the package ID
        this.model.set({ id: pid, seriesId: sid });
        const { model } = this;

        this.listenToOnce(model, "sync", function () {
          if (
            this.model.get("formatType") == "METADATA" ||
            !this.model.get("formatType")
          ) {
            this.model = model;
            this.renderMetadata();
          } else if (this.model.get("formatType") == "DATA") {
            // Get the metadata pids that document this data object
            const isDocBy = this.model.get("isDocumentedBy");

            // If there is only one metadata pid that documents this data object, then
            // get that metadata model for this view.
            if (isDocBy && isDocBy.length == 1) {
              this.navigateWithFragment(_.first(isDocBy), this.pid);

              return;
            }
            // If more than one metadata doc documents this data object, it is most likely
            // multiple versions of the same metadata. So we need to find the latest version.
            if (isDocBy && isDocBy.length > 1) {
              const view = this;

              require(["collections/Filters", "collections/SolrResults"], (
                Filters,
                SolrResults,
              ) => {
                // Create a search for the metadata docs that document this data object
                const searchFilters = new Filters([
                  {
                    values: isDocBy,
                    fields: ["id", "seriesId"],
                    operator: "OR",
                    fieldsOperator: "OR",
                    matchSubstring: false,
                  },
                ]);
                // Create a list of search results
                const searchResults = new SolrResults([], {
                  rows: isDocBy.length,
                  query: searchFilters.getQuery(),
                  fields: "obsoletes,obsoletedBy,id",
                });

                // When the search results are returned, process those results
                view.listenToOnce(searchResults, "sync", (searchResults) => {
                  // Keep track of the latest version of the metadata doc(s)
                  const latestVersions = [];

                  // Iterate over each search result and find the latest version of each metadata version chain
                  searchResults.each((searchResult) => {
                    // If this metadata isn't obsoleted by another object, it is the latest version
                    if (!searchResult.get("obsoletedBy")) {
                      latestVersions.push(searchResult.get("id"));
                    }
                    // If it is obsoleted by another object but that newer object does not document this data, then this is the latest version
                    else if (
                      !_.contains(isDocBy, searchResult.get("obsoletedBy"))
                    ) {
                      latestVersions.push(searchResult.get("id"));
                    }
                  }, view);

                  // If at least one latest version was found (should always be the case),
                  if (latestVersions.length) {
                    // Set that metadata pid as this view's pid and get that metadata model.
                    // TODO: Support navigation to multiple metadata docs. This should be a rare occurence, but
                    // it is possible that more than one metadata version chain documents a data object, and we need
                    // to show the user that the data is involved in multiple datasets.
                    view.navigateWithFragment(latestVersions[0], view.pid);
                  }
                  // If a latest version wasn't found, which should never happen, but just in case, default to the
                  // last metadata pid in the isDocumentedBy field (most liekly to be the most recent since it was indexed last).
                  else {
                    view.navigateWithFragment(_.last(isDocBy), view.pid);
                  }
                });

                // Send the query to the Solr search service
                searchResults.query();
              });

              return;
            }
            this.noMetadata(this.model);
          } else if (this.model.get("formatType") == "RESOURCE") {
            const packageModel = new Package({ id: this.model.get("id") });
            packageModel.on(
              "complete",
              function () {
                const metadata = packageModel.getMetadata();

                if (!metadata) {
                  this.noMetadata(packageModel);
                } else {
                  this.model = metadata;
                  this.pid = this.model.get("id");
                  this.renderMetadata();
                  if (this.model.get("resourceMap"))
                    this.getPackageDetails(this.model.get("resourceMap"));
                }
              },
              this,
            );
            packageModel.getMembers();
            return;
          }

          // Get the package information
          this.getPackageDetails(model.get("resourceMap"));
        });

        // Listen to 404 and 401 errors when we get the metadata object
        this.listenToOnce(model, "404", this.showNotFound);
        this.listenToOnce(model, "401", this.showIsPrivate);

        // Fetch the model
        model.getInfo();
      },

      renderMetadata() {
        const pid = this.model.get("id");

        this.hideLoading();
        // Load the template which holds the basic structure of the view
        this.$el.html(this.template());
        this.$(this.tableContainer).html(
          this.loadingTemplate({
            msg: "Retrieving data set details...",
          }),
        );

        // Insert the breadcrumbs
        this.insertBreadcrumbs();
        // Insert the citation
        this.insertCitation();
        // Insert the data source logo
        this.insertDataSource();
        // is this the latest version? (includes DOI link when needed)
        this.showLatestVersion();

        // Insert various metadata controls in the page
        this.insertControls();

        // If we're displaying the metrics well then display copy citation and edit button
        // inside the well
        if (MetacatUI.appModel.get("displayDatasetMetrics")) {
          // Insert Metrics Stats into the dataset landing pages
          this.insertMetricsControls();
        }

        // Show loading icon in metadata section
        this.$(this.metadataContainer).html(
          this.loadingTemplate({ msg: "Retrieving metadata ..." }),
        );

        // Check for a view service in this MetacatUI.appModel
        if (
          MetacatUI.appModel.get("viewServiceUrl") !== undefined &&
          MetacatUI.appModel.get("viewServiceUrl")
        )
          var endpoint =
            MetacatUI.appModel.get("viewServiceUrl") + encodeURIComponent(pid);

        if (endpoint && typeof endpoint !== "undefined") {
          const viewRef = this;
          const loadSettings = {
            url: endpoint,
            success(response, status, xhr) {
              try {
                // If the user has navigated away from the MetadataView, then don't render anything further
                if (MetacatUI.appView.currentView != viewRef) return;

                // Our fallback is to show the metadata details from the Solr index
                if (
                  status == "error" ||
                  !response ||
                  typeof response !== "string"
                )
                  viewRef.renderMetadataFromIndex();
                else {
                  // Check for a response that is a 200 OK status, but is an error msg
                  if (
                    response.length < 250 &&
                    response.indexOf("Error transforming document") > -1 &&
                    viewRef.model.get("indexed")
                  ) {
                    viewRef.renderMetadataFromIndex();
                    return;
                  }
                  // Mark this as a metadata doc with no stylesheet, or one that is at least different than usual EML and FGDC
                  if (response.indexOf('id="Metadata"') == -1) {
                    viewRef.$el.addClass("container no-stylesheet");

                    if (viewRef.model.get("indexed")) {
                      viewRef.renderMetadataFromIndex();
                      return;
                    }
                  }

                  // Now show the response from the view service
                  viewRef.$(viewRef.metadataContainer).html(response);

                  _.each($(response).find(".entitydetails"), (entityEl) => {
                    const entityId = $(entityEl).data("id");
                    viewRef.storeEntityPIDs(entityEl, entityId);
                  });

                  // If there is no info from the index and there is no metadata doc rendered either, then display a message
                  if (
                    viewRef.$el.is(".no-stylesheet") &&
                    viewRef.model.get("archived") &&
                    !viewRef.model.get("indexed")
                  )
                    viewRef.$(viewRef.metadataContainer).prepend(
                      viewRef.alertTemplate({
                        msg: "There is limited metadata about this dataset since it has been archived.",
                      }),
                    );

                  viewRef.alterMarkup();

                  viewRef.trigger("metadataLoaded");

                  // Add a map of the spatial coverage
                  if (gmaps) viewRef.insertSpatialCoverageMap();

                  // Injects Clipboard objects into DOM elements returned from the View Service
                  viewRef.insertCopiables();
                }
              } catch (e) {
                console.log(
                  "Error rendering metadata from the view service",
                  e,
                );
                console.log("Response from the view service: ", response);
                viewRef.renderMetadataFromIndex();
              }
            },
            error(xhr, textStatus, errorThrown) {
              viewRef.renderMetadataFromIndex();
            },
          };

          $.ajax(
            _.extend(loadSettings, MetacatUI.appUserModel.createAjaxSettings()),
          );
        } else this.renderMetadataFromIndex();

        // Insert the Linked Data into the header of the page.
        if (MetacatUI.appModel.get("isJSONLDEnabled")) {
          const json = this.generateJSONLD();
          this.insertJSONLD(json);
        }

        this.insertCitationMetaTags();
      },

      /* If there is no view service available, then display the metadata fields from the index */
      renderMetadataFromIndex() {
        const metadataFromIndex = new MetadataIndex({
          pid: this.pid,
          parentView: this,
        });
        this.subviews.push(metadataFromIndex);

        // Add the metadata HTML
        this.$(this.metadataContainer).html(metadataFromIndex.render().el);

        const view = this;

        this.listenTo(metadataFromIndex, "complete", () => {
          // Add the package contents
          view.insertPackageDetails();

          // Add a map of the spatial coverage
          if (gmaps) view.insertSpatialCoverageMap();
        });
      },

      removeCitation() {
        let citation = "";
        let citationEl = null;

        // Find the citation element
        if (this.$(".citation").length > 0) {
          // Get the text for the citation
          citation = this.$(".citation").text();

          // Save this element in the view
          citationEl = this.$(".citation");
        }
        // Older versions of Metacat (v2.4.3 and older) will not have the citation class in the XSLT. Find the citation another way
        else {
          // Find the DOM element with the citation
          const wells = this.$(".well");
          const viewRef = this;

          // Find the div.well with the citation. If we never find it, we don't insert the list of contents
          _.each(wells, (well) => {
            if (
              (!citationEl &&
                $(well).find("#viewMetadataCitationLink").length > 0) ||
              $(well).children(".row-fluid > .span10 > a")
            ) {
              // Save this element in the view
              citationEl = well;

              // Mark this in the DOM for CSS styling
              $(well).addClass("citation");

              // Save the text of the citation
              citation = $(well).text();
            }
          });

          // Remove the unnecessary classes that are used in older versions of Metacat (2.4.3 and older)
          const citationText = $(citationEl).find(".span10");
          $(citationText).removeClass("span10").addClass("span12");
        }

        // Set the document title to the citation
        MetacatUI.appModel.set("title", citation);

        citationEl.remove();
      },

      insertBreadcrumbs() {
        const breadcrumbs = $(document.createElement("ol"))
          .addClass("breadcrumb")
          .append(
            $(document.createElement("li"))
              .addClass("home")
              .append(
                $(document.createElement("a"))
                  .attr("href", MetacatUI.root || "/")
                  .addClass("home")
                  .text("Home"),
              ),
          )
          .append(
            $(document.createElement("li"))
              .addClass("search")
              .append(
                $(document.createElement("a"))
                  .attr(
                    "href",
                    `${MetacatUI.root}/data${
                      MetacatUI.appModel.get("page") > 0
                        ? `/page/${
                            parseInt(MetacatUI.appModel.get("page")) + 1
                          }`
                        : ""
                    }`,
                  )
                  .addClass("search")
                  .text("Search"),
              ),
          )
          .append(
            $(document.createElement("li")).append(
              $(document.createElement("a"))
                .attr(
                  "href",
                  `${MetacatUI.root}/view/${encodeURIComponent(this.pid)}`,
                )
                .addClass("inactive")
                .text("Metadata"),
            ),
          );

        if (MetacatUI.uiRouter.lastRoute() == "data") {
          $(breadcrumbs).prepend(
            $(document.createElement("a"))
              .attr(
                "href",
                `${MetacatUI.root}/data/page/${
                  MetacatUI.appModel.get("page") > 0
                    ? parseInt(MetacatUI.appModel.get("page")) + 1
                    : ""
                }`,
              )
              .attr("title", "Back")
              .addClass("back")
              .text(" Back to search")
              .prepend(
                $(document.createElement("i")).addClass("icon-angle-left"),
              ),
          );
          $(breadcrumbs).find("a.search").addClass("inactive");
        }

        this.$(this.breadcrumbContainer).html(breadcrumbs);
      },

      /*
       * When the metadata object doesn't exist, display a message to the user
       */
      showNotFound() {
        // If the model was found, exit this function
        if (!this.model.get("notFound")) {
          return;
        }

        try {
          // Check if a query string was in the URL and if so, try removing it in the identifier
          if (this.model.get("id").match(/\?\S+\=\S+/g) && !this.findTries) {
            const newID = this.model.get("id").replace(/\?\S+\=\S+/g, "");
            this.onClose();
            this.model.set("id", newID);
            this.pid = newID;
            this.findTries = 1;
            this.render();
            return;
          }
        } catch (e) {
          console.warn("Caught error while determining query string", e);
        }

        // Construct a message that shows this object doesn't exist
        const msg =
          `<h4>Nothing was found.</h4>` +
          `<p id='metadata-view-not-found-message'>The dataset identifier '${Utilities.encodeHTML(
            this.model.get("id"),
          )}' ` +
          `does not exist or it may have been removed. <a>Search for ` +
          `datasets that mention ${Utilities.encodeHTML(
            this.model.get("id"),
          )}</a></p>`;

        // Remove the loading message
        this.hideLoading();

        // Show the not found error message
        this.showError(msg);

        // Add the pid to the link href. Add via JS so it is Attribute-encoded to prevent XSS attacks
        this.$("#metadata-view-not-found-message a").attr(
          "href",
          `${MetacatUI.root}/data/query=${encodeURIComponent(
            this.model.get("id"),
          )}`,
        );
      },

      /*
       * When the metadata object is private, display a message to the user
       */
      showIsPrivate() {
        // If we haven't checked the logged-in status of the user yet, wait a bit
        // until we show a 401 msg, in case this content is their private content
        if (!MetacatUI.appUserModel.get("checked")) {
          this.listenToOnce(
            MetacatUI.appUserModel,
            "change:checked",
            this.showIsPrivate,
          );
          return;
        }

        // If the user is logged in, the message will display that this dataset is private.
        if (MetacatUI.appUserModel.get("loggedIn")) {
          var msg =
            '<span class="icon-stack private tooltip-this" data-toggle="tooltip"' +
            'data-placement="top" data-container="#metadata-controls-container"' +
            'title="" data-original-title="This is a private dataset.">' +
            '<i class="icon icon-circle icon-stack-base private"></i>' +
            '<i class="icon icon-lock icon-stack-top"></i>' +
            "</span> This is a private dataset.";
        }
        // If the user isn't logged in, display a log in link.
        else {
          var msg =
            `<span class="icon-stack private tooltip-this" data-toggle="tooltip"` +
            `data-placement="top" data-container="#metadata-controls-container"` +
            `title="" data-original-title="This is a private dataset.">` +
            `<i class="icon icon-circle icon-stack-base private"></i>` +
            `<i class="icon icon-lock icon-stack-top"></i>` +
            `</span> This is a private dataset. If you believe you have permission ` +
            `to access this dataset, then <a href="${MetacatUI.root}/signin">sign in</a>.`;
        }

        // Remove the loading message
        this.hideLoading();

        // Show the not found error message
        this.showError(msg);
      },

      getPackageDetails(packageIDs) {
        let completePackages = 0;

        // This isn't a package, but just a lonely metadata doc...
        if (!packageIDs || !packageIDs.length) {
          const thisPackage = new Package({ id: null, members: [this.model] });
          thisPackage.flagComplete();
          this.packageModels = [thisPackage];
          this.insertPackageDetails(thisPackage, {
            disablePackageDownloads: true,
          });
        } else {
          _.each(
            packageIDs,
            function (thisPackageID, i) {
              // Create a model representing the data package
              const thisPackage = new Package({ id: thisPackageID });

              // Listen for any parent packages
              this.listenToOnce(
                thisPackage,
                "change:parentPackageMetadata",
                this.insertParentLink,
              );

              // When the package info is fully retrieved
              this.listenToOnce(
                thisPackage,
                "complete",
                function (thisPackage) {
                  // When all packages are fully retrieved
                  completePackages++;
                  if (completePackages >= packageIDs.length) {
                    const latestPackages = _.filter(
                      this.packageModels,
                      (m) => !_.contains(packageIDs, m.get("obsoletedBy")),
                    );

                    // Set those packages as the most recent package
                    this.packageModels = latestPackages;

                    this.insertPackageDetails(latestPackages);
                  }
                },
              );

              // Save the package in the view
              this.packageModels.push(thisPackage);

              // Make sure we get archived content, too
              thisPackage.set("getArchivedMembers", true);

              // Get the members
              thisPackage.getMembers({ getParentMetadata: true });
            },
            this,
          );
        }
      },

      alterMarkup() {
        // Find the taxonomic range and give it a class for styling - for older versions of Metacat only (v2.4.3 and older)
        if (!this.$(".taxonomicCoverage").length)
          this.$('h4:contains("Taxonomic Range")')
            .parent()
            .addClass("taxonomicCoverage");

        // Remove ecogrid links and replace them with workable links
        this.replaceEcoGridLinks();

        // Find the tab links for attribute names
        this.$(".attributeListTable tr a").on("shown", (e) => {
          // When the attribute link is clicked on, highlight the tab as active
          $(e.target)
            .parents(".attributeListTable")
            .find(".active")
            .removeClass("active");
          $(e.target).parents("tr").first().addClass("active");
        });

        // Mark the first row in each attribute list table as active since the first attribute is displayed at first
        this.$(".attributeListTable tr:first-child()").addClass("active");

        // Add explanation text to the alternate identifier
        this.renderAltIdentifierHelpText();
      },

      /**
       * Inserts an info icon next to the alternate identifier field, if it
       * exists. The icon will display a tooltip with the help text for the
       * field.
       * @returns {jQuery} The jQuery object for the icon element.
       * @since 2.26.0
       */
      renderAltIdentifierHelpText() {
        try {
          // Find the HTML element that contains the alternate identifier.
          const altIdentifierLabel = this.$(
            ".control-label:contains('Alternate Identifier')",
          );

          // It may not exist for all datasets.
          if (!altIdentifierLabel.length) return;

          const text = this.alternativeIdentifierHelpText;

          if (!text) return;

          // Create the tooltip
          const icon = $(document.createElement("i"))
            .addClass("tooltip-this icon icon-info-sign")
            .css("margin-left", "4px");

          // Activate the jQuery tooltip plugin
          icon.tooltip({
            title: text,
            placement: "top",
            container: "body",
          });

          // Add the icon to the label.
          altIdentifierLabel.append(icon);

          return icon;
        } catch (e) {
          console.log("Error adding help text to alternate identifier", e);
        }
      },

      /*
       * Inserts a table with all the data package member information and sends the call to display annotations
       */
      insertPackageDetails(packages, options) {
        if (typeof options === "undefined") {
          var options = {};
        }
        // Don't insert the package details twice
        const view = this;
        const tableEls = this.$(view.tableContainer).children().not(".loading");
        if (tableEls.length > 0) return;

        // wait for the metadata to load
        const metadataEls = this.$(view.metadataContainer).children();
        if (!metadataEls.length || metadataEls.first().is(".loading")) {
          this.once("metadataLoaded", function () {
            view.insertPackageDetails(this.packageModels, options);
          });
          return;
        }

        if (!packages) var packages = this.packageModels;

        // Get the entity names from this page/metadata
        this.getEntityNames(packages);

        _.each(
          packages,
          function (packageModel) {
            // If the package model is not complete, don't do anything
            if (!packageModel.complete) return;

            // Insert a package table for each package in viewRef dataset
            const nestedPckgs = packageModel.getNestedPackages();
            let nestedPckgsToDisplay = [];

            // If this metadata is not archived, filter out archived packages
            if (!this.model.get("archived")) {
              nestedPckgsToDisplay = _.reject(nestedPckgs, (pkg) =>
                pkg.get("archived"),
              );
            } else {
              // Display all packages is this metadata is archived
              nestedPckgsToDisplay = nestedPckgs;
            }

            if (nestedPckgsToDisplay.length > 0) {
              if (
                !(
                  !this.model.get("archived") &&
                  packageModel.get("archived") == true
                )
              ) {
                var title = packageModel.get("id")
                  ? `<span class="subtle">Package: ${packageModel.get(
                      "id",
                    )}</span>`
                  : "";
                options.title = `Files in this dataset ${title}`;
                options.nested = true;
                this.insertPackageTable(packageModel, options);
              }
            } else {
              // If this metadata is not archived, then don't display archived packages
              if (
                !(
                  !this.model.get("archived") &&
                  packageModel.get("archived") == true
                )
              ) {
                var title = packageModel.get("id")
                  ? `<span class="subtle">Package: ${packageModel.get(
                      "id",
                    )}</span>`
                  : "";
                options.title = `Files in this dataset ${title}`;
                this.insertPackageTable(packageModel, options);
              }
            }

            // Remove the extra download button returned from the XSLT since the package table will have all the download links
            $("#downloadPackage").remove();
          },
          this,
        );

        // If this metadata doc is not in a package, but is just a lonely metadata doc...
        if (!packages.length) {
          const packageModel = new Package({
            members: [this.model],
          });
          packageModel.complete = true;
          options.title = "Files in this dataset";
          options.disablePackageDownloads = true;
          this.insertPackageTable(packageModel, options);
        }

        // Insert the data details sections
        this.insertDataDetails();

        // Get data package, if there is one, before checking write permissions
        if (packages.length) {
          this.getDataPackage(packages[0].get("id"));
        } else {
          // Otherwise go ahead and check write permissions on metadata only
          this.checkWritePermissions();
        }

        try {
          // Get the most recent package to display the provenance graphs
          if (packages.length) {
            // Find the most recent Package model and fetch it
            let mostRecentPackage = _.find(
              packages,
              (p) => !p.get("obsoletedBy"),
            );

            // If all of the packages are obsoleted, then use the last package in the array,
            // which is most likely the most recent.
            /** @todo Use the DataONE version API to find the most recent package in the version chain */
            if (!mostRecentPackage) {
              mostRecentPackage = packages[packages.length - 1];
            }

            // Get the data package only if it is not the same as the previously fetched package
            if (mostRecentPackage.get("id") != packages[0].get("id"))
              this.getDataPackage(mostRecentPackage.get("id"));
          }
        } catch (e) {
          console.error(
            "Could not get the data package (prov will not be displayed, possibly other info as well).",
            e,
          );
        }

        // Initialize tooltips in the package table(s)
        this.$(".tooltip-this").tooltip();

        return this;
      },

      insertPackageTable(packageModel, options) {
        const view = this;
        if (this.dataPackage == null || !this.dataPackageSynced) {
          this.listenToOnce(this, "changed:dataPackageSynced", () => {
            view.insertPackageTable(packageModel, options);
          });
          return;
        }

        // Merge already fetched SolrResults into the dataPackage
        if (
          typeof packageModel !== "undefined" &&
          typeof packageModel.get("members") !== "undefined"
        ) {
          this.dataPackage.mergeModels(packageModel.get("members"));
        }

        if (options) {
          var title = options.title || "";
          var disablePackageDownloads =
            options.disablePackageDownloads || false;
          var nested =
            typeof options.nested === "undefined" ? false : options.nested;
        } else
          var title = "",
            nested = false,
            disablePackageDownloads = false;

        //* * Draw the package table **//
        const tableView = new DataPackageView({
          edit: false,
          dataPackage: this.dataPackage,
          currentlyViewing: this.pid,
          dataEntities: this.entities,
          disablePackageDownloads,
          parentView: this,
          title,
          packageTitle: this.model.get("title"),
          nested,
          metricsModel: this.metricsModel,
        });

        // Get the package table container
        const tablesContainer = this.$(this.tableContainer);

        // After the first table, start collapsing them
        const numTables = $(tablesContainer).find(
          "table.download-contents",
        ).length;
        if (numTables == 1) {
          var tableContainer = $(document.createElement("div")).attr(
            "id",
            `additional-tables-for-${this.cid}`,
          );
          tableContainer.hide();
          $(tablesContainer).append(tableContainer);
        } else if (numTables > 1)
          var tableContainer = this.$(`#additional-tables-for-${this.cid}`);
        else var tableContainer = tablesContainer;

        // Insert the package table HTML
        $(tableContainer).empty();
        $(tableContainer).append(tableView.render().el);

        // Add Package Download
        // create an instance of DownloadButtonView to handle package downloads
        this.downloadButtonView = new DownloadButtonView({
          model: packageModel,
          view: "actionsView",
        });

        // render
        this.downloadButtonView.render();

        // add the downloadButtonView el to the span
        $(this.tableContainer)
          .find(".file-header .file-actions .downloadAction")
          .html(this.downloadButtonView.el);

        $(this.tableContainer).find(".loading").remove();

        $(tableContainer).find(".tooltip-this").tooltip();

        this.subviews.push(tableView);

        // Trigger a custom event in this view that indicates the package table has been rendered
        this.trigger("dataPackageRendered");
      },

      insertParentLink(packageModel) {
        const parentPackageMetadata = packageModel.get("parentPackageMetadata");
        const view = this;

        _.each(parentPackageMetadata, (m, i) => {
          const title = m.get("title");
          const icon = $(document.createElement("i")).addClass(
            "icon icon-on-left icon-level-up",
          );
          const link = $(document.createElement("a"))
            .attr(
              "href",
              `${MetacatUI.root}/view/${encodeURIComponent(m.get("id"))}`,
            )
            .addClass("parent-link")
            .text(`Parent dataset: ${title}`)
            .prepend(icon);

          view.$(view.parentLinkContainer).append(link);
        });
      },

      insertSpatialCoverageMap(customCoordinates) {
        // Find the geographic region container. Older versions of Metacat (v2.4.3 and less) will not have it classified so look for the header text
        if (!this.$(".geographicCoverage").length) {
          // For EML
          let title = this.$('h4:contains("Geographic Region")');

          // For FGDC
          if (title.length == 0) {
            title = this.$('label:contains("Bounding Coordinates")');
          }

          var georegionEls = $(title).parent();
          var parseText = true;
          var directions = new Array("North", "South", "East", "West");
        } else {
          var georegionEls = this.$(".geographicCoverage");
          var directions = new Array("north", "south", "east", "west");
        }

        for (let i = 0; i < georegionEls.length; i++) {
          var georegion = georegionEls[i];

          if (typeof customCoordinates !== "undefined") {
            // Extract the coordinates
            var n = customCoordinates[0];
            var s = customCoordinates[1];
            var e = customCoordinates[2];
            var w = customCoordinates[3];
          } else {
            var coordinates = new Array();

            _.each(directions, (direction) => {
              // Parse text for older versions of Metacat (v2.4.3 and earlier)
              if (parseText) {
                const labelEl = $(georegion).find(
                  `label:contains("${direction}")`,
                );
                if (labelEl.length) {
                  var coordinate = $(labelEl).next().html();
                  if (
                    typeof coordinate !== "undefined" &&
                    coordinate.indexOf("&nbsp;") > -1
                  )
                    coordinate = coordinate.substring(
                      0,
                      coordinate.indexOf("&nbsp;"),
                    );
                }
              } else {
                var coordinate = $(georegion)
                  .find(`.${direction}BoundingCoordinate`)
                  .attr("data-value");
              }

              // Save our coordinate value
              coordinates.push(coordinate);
            });

            // Extract the coordinates
            var n = coordinates[0];
            var s = coordinates[1];
            var e = coordinates[2];
            var w = coordinates[3];
          }

          // Create Google Map LatLng objects out of our coordinates
          const latLngSW = new gmaps.LatLng(s, w);
          const latLngNE = new gmaps.LatLng(n, e);
          const latLngNW = new gmaps.LatLng(n, w);
          const latLngSE = new gmaps.LatLng(s, e);

          // Get the centertroid location of this data item
          const bounds = new gmaps.LatLngBounds(latLngSW, latLngNE);
          const latLngCEN = bounds.getCenter();

          // If there isn't a center point found, don't draw the map.
          if (typeof latLngCEN === "undefined") {
            return;
          }

          // Get the map path color
          let pathColor = MetacatUI.appModel.get("datasetMapPathColor");
          if (pathColor) {
            pathColor = `color:${pathColor}|`;
          } else {
            pathColor = "";
          }

          // Get the map path fill color
          let fillColor = MetacatUI.appModel.get("datasetMapFillColor");
          if (fillColor) {
            fillColor = `fillcolor:${fillColor}|`;
          } else {
            fillColor = "";
          }

          // Create a google map image
          const mapHTML =
            `<img class='georegion-map' ` +
            `src='https://maps.googleapis.com/maps/api/staticmap?` +
            `center=${latLngCEN.lat()},${latLngCEN.lng()}&size=800x350` +
            `&maptype=terrain` +
            `&markers=size:mid|color:0xDA4D3Aff|${latLngCEN.lat()},${latLngCEN.lng()}&path=${fillColor}${pathColor}weight:3|${latLngSW.lat()},${latLngSW.lng()}|${latLngNW.lat()},${latLngNW.lng()}|${latLngNE.lat()},${latLngNE.lng()}|${latLngSE.lat()},${latLngSE.lng()}|${latLngSW.lat()},${latLngSW.lng()}&visible=${latLngSW.lat()},${latLngSW.lng()}|${latLngNW.lat()},${latLngNW.lng()}|${latLngNE.lat()},${latLngNE.lng()}|${latLngSE.lat()},${latLngSE.lng()}|${latLngSW.lat()},${latLngSW.lng()}&sensor=false` +
            `&key=${MetacatUI.mapKey}'/>`;

          // Find the spot in the DOM to insert our map image
          if (parseText)
            var insertAfter = $(georegion)
              .find('label:contains("West")')
              .parent()
              .parent().length
              ? $(georegion).find('label:contains("West")').parent().parent()
              : georegion;
          // The last coordinate listed
          else var insertAfter = georegion;

          // Get the URL to the interactive Google Maps instance
          const url = this.getGoogleMapsUrl(latLngCEN, bounds);

          // Insert the map image
          $(insertAfter).append(
            this.mapTemplate({
              map: mapHTML,
              url,
            }),
          );

          $(".fancybox-media").fancybox({
            openEffect: "elastic",
            closeEffect: "elastic",
            helpers: {
              media: {},
            },
          });
        }

        return true;
      },

      /**
       * Returns a URL to a Google Maps instance that is centered on the given
       * coordinates and zoomed to the appropriate level to display the given
       * bounding box.
       * @param {LatLng} latLngCEN - The center point of the map.
       * @param {LatLngBounds} bounds - The bounding box to display.
       * @returns {string} The URL to the Google Maps instance.
       * @since 2.27.0
       */
      getGoogleMapsUrl(latLngCEN, bounds) {
        // Use the window width and height as a proxy for the map dimensions
        const mapDim = {
          height: $(window).height(),
          width: $(window).width(),
        };
        const z = this.getBoundsZoomLevel(bounds, mapDim);
        const mapLat = latLngCEN.lat();
        const mapLng = latLngCEN.lng();

        return `https://maps.google.com/?ll=${mapLat},${mapLng}&z=${z}`;
      },

      /**
       * Returns the zoom level that will display the given bounding box at
       * the given dimensions.
       * @param {LatLngBounds} bounds - The bounding box to display.
       * @param {object} mapDim - The dimensions of the map.
       * @param {number} mapDim.height - The height of the map.
       * @param {number} mapDim.width - The width of the map.
       * @returns {number} The zoom level.
       * @since 2.27.0
       */
      getBoundsZoomLevel(bounds, mapDim) {
        const WORLD_DIM = { height: 256, width: 256 };
        const ZOOM_MAX = 15;
        // 21 is actual max, but any closer and the map is too zoomed in to be
        // useful

        /**
         *
         * @param lat
         */
        function latRad(lat) {
          const sin = Math.sin((lat * Math.PI) / 180);
          const radX2 = Math.log((1 + sin) / (1 - sin)) / 2;
          return Math.max(Math.min(radX2, Math.PI), -Math.PI) / 2;
        }

        /**
         *
         * @param mapPx
         * @param worldPx
         * @param fraction
         */
        function zoom(mapPx, worldPx, fraction) {
          return Math.floor(Math.log(mapPx / worldPx / fraction) / Math.LN2);
        }

        const ne = bounds.getNorthEast();
        const sw = bounds.getSouthWest();

        const latFraction = (latRad(ne.lat()) - latRad(sw.lat())) / Math.PI;

        const lngDiff = ne.lng() - sw.lng();
        const lngFraction = (lngDiff < 0 ? lngDiff + 360 : lngDiff) / 360;

        const latZoom = zoom(mapDim.height, WORLD_DIM.height, latFraction);
        const lngZoom = zoom(mapDim.width, WORLD_DIM.width, lngFraction);

        return Math.min(latZoom, lngZoom, ZOOM_MAX);
      },

      insertCitation() {
        if (!this.model) return false;
        // Create a citation header element from the model attributes
        const header = new CitationHeaderView({ model: this.model });
        this.$(this.citationContainer).html(header.render().el);
      },

      insertDataSource() {
        if (
          !this.model ||
          !MetacatUI.nodeModel ||
          !MetacatUI.nodeModel.get("members").length ||
          !this.$(this.dataSourceContainer).length
        )
          return;

        const dataSource = MetacatUI.nodeModel.getMember(this.model);
        let replicaMNs = MetacatUI.nodeModel.getMembers(
          this.model.get("replicaMN"),
        );

        // Filter out the data source from the replica nodes
        if (Array.isArray(replicaMNs) && replicaMNs.length) {
          replicaMNs = _.without(replicaMNs, dataSource);
        }

        if (dataSource && dataSource.logo) {
          this.$("img.data-source").remove();

          // Construct a URL to the profile of this repository
          const profileURL =
            dataSource.identifier == MetacatUI.appModel.get("nodeId")
              ? `${MetacatUI.root}/profile`
              : `${MetacatUI.appModel.get("dataoneSearchUrl")}/portals/${
                  dataSource.shortIdentifier
                }`;

          // Insert the data source template
          this.$(this.dataSourceContainer)
            .html(
              this.dataSourceTemplate({
                node: dataSource,
                profileURL,
              }),
            )
            .addClass("has-data-source");

          this.$(this.citationContainer).addClass("has-data-source");
          this.$(".tooltip-this").tooltip();

          $(".popover-this.data-source.logo")
            .popover({
              trigger: "manual",
              html: true,
              title: `From the ${dataSource.name} repository`,
              content() {
                let content = `<p>${dataSource.description}</p>`;

                if (replicaMNs.length) {
                  content += `<h5>Exact copies hosted by ${replicaMNs.length} repositories: </h5><ul class="unstyled">`;

                  _.each(replicaMNs, (node) => {
                    content += `<li><a href="${MetacatUI.appModel.get(
                      "dataoneSearchUrl",
                    )}/portals/${node.shortIdentifier}" class="pointer">${
                      node.name
                    }</a></li>`;
                  });

                  content += "</ul>";
                }

                return content;
              },
              animation: false,
            })
            .on("mouseenter", function () {
              const _this = this;
              $(this).popover("show");
              $(".popover").on("mouseleave", () => {
                $(_this).popover("hide");
              });
            })
            .on("mouseleave", function () {
              const _this = this;
              setTimeout(() => {
                if (!$(".popover:hover").length) {
                  $(_this).popover("hide");
                }
              }, 300);
            });
        }
      },

      /**
       * Check whether the user has write permissions on the resource map and the EML.
       * Once the permission checks have finished, continue with the functions that
       * depend on them.
       */
      checkWritePermissions() {
        const view = this;
        const authorization = [];
        const resourceMap = this.dataPackage
          ? this.dataPackage.packageModel
          : null;
        const modelsToCheck = [this.model, resourceMap];

        modelsToCheck.forEach((model, index) => {
          // If there is no resource map or no EML,
          // then the user does not need permission to edit it.
          if (!model || model.get("notFound") == true) {
            authorization[index] = true;
            // If we already checked, and the user is authorized,
            // record that information in the authorzation array.
          } else if (model.get("isAuthorized_write") === true) {
            authorization[index] = true;
            // If we already checked, and the user is not authorized,
            // record that information in the authorzation array.
          } else if (model.get("isAuthorized_write") === false) {
            authorization[index] = false;
            // If we haven't checked for authorization yet, do that now.
            // Return to this function once we've finished checking.
          } else {
            view.stopListening(model, "change:isAuthorized_write");
            view.listenToOnce(model, "change:isAuthorized_write", () => {
              view.checkWritePermissions();
            });
            view.stopListening(model, "change:notFound");
            view.listenToOnce(model, "change:notFound", () => {
              view.checkWritePermissions();
            });
            model.checkAuthority("write");
          }
        });

        // Check that all the models were tested for authorization

        // Every value in the auth array must be true for the user to have full permissions
        const allTrue = _.every(authorization, (test) => test);
        // When we have completed checking each of the models that we need to check for
        // permissions, every value in the authorization array should be "true" or "false",
        // and the array should have the same length as the modelsToCheck array.
        const allBoolean = _.every(
          authorization,
          (test) => typeof test === "boolean",
        );
        const allChecked =
          allBoolean && authorization.length === modelsToCheck.length;

        // Check for and render prov diagrams now that we know whether or not the user has editor permissions
        // (There is a different version of the chart for users who can edit the resource map and users who cannot)
        if (allChecked) {
          this.checkForProv();
        } else {
          return;
        }
        // Only render the editor controls if we have completed the checks AND the user has full editor permissions
        if (allTrue) {
          this.insertEditorControls();
        }
      },

      /*
       * Inserts control elements onto the page for the user to interact with the dataset - edit, publish, etc.
       * Editor permissions should already have been checked before running this function.
       */
      insertEditorControls() {
        const view = this;
        const resourceMap = this.dataPackage
          ? this.dataPackage.packageModel
          : null;
        const modelsToCheck = [this.model, resourceMap];
        const authorized = _.every(modelsToCheck, (model) =>
          // If there is no EML or no resource map, the user doesn't need permission to edit it.
          !model || model.get("notFound") == true
            ? true
            : model.get("isAuthorized_write") === true,
        );

        // Only run this function when the user has full editor permissions
        // (i.e. write permission on the EML, and write permission on the resource map if there is one.)
        if (!authorized) {
          return;
        }

        if (
          (this.model.get("obsoletedBy") &&
            this.model.get("obsoletedBy").length > 0) ||
          this.model.get("archived")
        ) {
          return false;
        }

        // Save the element that will contain the owner control buttons
        const container = this.$(this.editorControlsContainer);
        // Do not insert the editor controls twice
        container.empty();

        // The PID for the EML model
        const pid = this.model.get("id") || this.pid;

        // Insert an Edit button if the Edit button is enabled
        if (MetacatUI.appModel.get("displayDatasetEditButton")) {
          // Check that this is an editable metadata format
          if (
            _.contains(
              MetacatUI.appModel.get("editableFormats"),
              this.model.get("formatId"),
            )
          ) {
            // Insert the Edit Metadata template
            container.append(
              this.editMetadataTemplate({
                identifier: pid,
                supported: true,
              }),
            );
          }
          // If this format is not editable, insert an unspported Edit Metadata template
          else {
            container.append(
              this.editMetadataTemplate({
                supported: false,
              }),
            );
          }
        }

        try {
          // Determine if this metadata can be published.
          // The Publish feature has to be enabled in the app.
          // The model cannot already have a DOI
          let canBePublished =
            MetacatUI.appModel.get("enablePublishDOI") && !view.model.isDOI();

          // If publishing is enabled, check if only certain users and groups can publish metadata
          if (canBePublished) {
            // Get the list of authorized publishers from the AppModel
            const authorizedPublishers = MetacatUI.appModel.get(
              "enablePublishDOIForSubjects",
            );
            // If the logged-in user is one of the subjects in the list or is in a group that is
            // in the list, then this metadata can be published. Otherwise, it cannot.
            if (
              Array.isArray(authorizedPublishers) &&
              authorizedPublishers.length
            ) {
              if (
                MetacatUI.appUserModel.hasIdentityOverlap(authorizedPublishers)
              ) {
                canBePublished = true;
              } else {
                canBePublished = false;
              }
            }
          }

          // If this metadata can be published, then insert the Publish button template
          if (canBePublished) {
            // Insert a Publish button template
            container.append(
              view.doiTemplate({
                isAuthorized: true,
                identifier: pid,
              }),
            );
          }
        } catch (e) {
          console.error("Cannot display the publish button: ", e);
        }
      },

      /*
       * Injects Clipboard objects onto DOM elements returned from the Metacat
       * View Service. This code depends on the implementation of the Metacat
       * View Service in that it depends on elements with the class "copy" being
       * contained in the HTML returned from the View Service.
       *
       * To add more copiable buttons (or other elements) to a View Service XSLT,
       * you should be able to just add something like:
       *
       *   <button class="btn copy" data-clipboard-text="your-text-to-copy">
       *      Copy
       *   </button>
       *
       * to your XSLT and this should pick it up automatically.
       */
      insertCopiables() {
        const copiables = $("#Metadata .copy");

        _.each(copiables, (copiable) => {
          const clipboard = new Clipboard(copiable);

          clipboard.on("success", (e) => {
            const el = $(e.trigger);

            $(el).html(
              $(document.createElement("span")).addClass(
                "icon icon-ok success",
              ),
            );

            // Use setTimeout instead of jQuery's built-in Events system because
            // it didn't look flexible enough to allow me update innerHTML in
            // a chain
            setTimeout(() => {
              $(el).html("Copy");
            }, 500);
          });
        });
      },

      /*
       * Inserts elements users can use to interact with this dataset:
       * - A "Copy Citation" button to copy the citation text
       */
      insertControls() {
        // Convert the support mdq formatId list to a version
        // that JS regex likes (with special characters double
        RegExp.escape = function (s) {
          return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\\\$&");
        };
        const mdqFormatIds = MetacatUI.appModel.get("mdqFormatIds");

        // Check of the current formatId is supported by the current
        // metadata quality suite. If not, the 'Assessment Report' button
        // will not be displacyed in the metadata controls panel.
        const thisFormatId = this.model.get("formatId");
        const mdqFormatSupported = false;
        let formatFound = false;
        if (mdqFormatIds !== null) {
          for (let ifmt = 0; ifmt < mdqFormatIds.length; ++ifmt) {
            const currentFormatId = RegExp.escape(mdqFormatIds[ifmt]);
            const re = new RegExp(currentFormatId);
            formatFound = re.test(thisFormatId);
            if (formatFound) {
              break;
            }
          }
        }

        // Get template
        const controlsContainer = this.controlsTemplate({
          citationTarget: this.citationContainer,
          url: window.location,
          displayQualtyReport:
            MetacatUI.appModel.get("mdqBaseUrl") &&
            formatFound &&
            MetacatUI.appModel.get("displayDatasetQualityMetric"),
          showWholetale: MetacatUI.appModel.get("showWholeTaleFeatures"),
          model: this.model.toJSON(),
        });

        $(this.controlsContainer).html(controlsContainer);

        // Insert the info icons
        const metricsWell = this.$(".metrics-container");
        metricsWell.append(
          this.infoIconsTemplate({
            model: this.model.toJSON(),
          }),
        );

        if (MetacatUI.appModel.get("showWholeTaleFeatures")) {
          this.createWholeTaleButton();
        }

        // Show the citation modal with the ability to copy the citation text
        // when the "Copy Citation" button is clicked
        const citeButton = this.el.querySelector("#cite-this-dataset-btn");
        if (citeButton) {
          citeButton.removeEventListener("click", this.citationModal);
          citeButton.addEventListener(
            "click",
            () => {
              this.citationModal = new CitationModalView({
                model: this.model,
                createLink: true,
              });
              this.subviews.push(this.citationModal);
              this.citationModal.render();
            },
            false,
          );
        }
      },

      /**
       *Creates a button which the user can click to launch the package in Whole Tale
       */
      createWholeTaleButton() {
        const self = this;
        MetacatUI.appModel.get("taleEnvironments").forEach((environment) => {
          const queryParams = `?uri=${
            window.location.href
          }&title=${encodeURIComponent(
            self.model.get("title"),
          )}&environment=${environment}&api=${MetacatUI.appModel.get(
            "d1CNBaseUrl",
          )}${MetacatUI.appModel.get("d1CNService")}`;
          const composeUrl =
            MetacatUI.appModel.get("dashboardUrl") + queryParams;
          const anchor = $("<a>");
          anchor
            .attr("href", composeUrl)
            .append($("<span>").attr("class", "tab").append(environment));
          anchor.attr("target", "_blank");
          $(".analyze.dropdown-menu").append($("<li>").append(anchor));
        });
      },

      // Inserting the Metric Stats
      insertMetricsControls() {
        // Exit if metrics shouldn't be shown for this dataset
        if (this.model.hideMetrics()) {
          return;
        }

        const pid_list = [];
        pid_list.push(this.pid);
        const metricsModel = new MetricsModel({
          pid_list,
          type: "dataset",
        });
        metricsModel.fetch();
        this.metricsModel = metricsModel;

        // Retreive the model from the server for the given PID
        // TODO: Create a Metric Request Object

        if (MetacatUI.appModel.get("displayDatasetMetrics")) {
          const buttonToolbar = this.$(".metrics-container");

          if (MetacatUI.appModel.get("displayDatasetDownloadMetric")) {
            const dwnldsMetricView = new MetricView({
              metricName: "Downloads",
              model: metricsModel,
              pid: this.pid,
            });
            buttonToolbar.append(dwnldsMetricView.render().el);
            this.subviews.push(dwnldsMetricView);
          }

          if (MetacatUI.appModel.get("displayDatasetCitationMetric")) {
            const citationsMetricView = new MetricView({
              metricName: "Citations",
              model: metricsModel,
              pid: this.pid,
            });
            buttonToolbar.append(citationsMetricView.render().el);
            this.subviews.push(citationsMetricView);

            try {
              // Check if the registerCitation=true query string is set
              if (window.location.search) {
                if (
                  window.location.search.indexOf("registerCitation=true") > -1
                ) {
                  // Open the modal for the citations
                  citationsMetricView.showMetricModal();

                  // Show the register citation form
                  if (citationsMetricView.modalView) {
                    citationsMetricView.modalView.on(
                      "renderComplete",
                      citationsMetricView.modalView.showCitationForm,
                    );
                  }
                }
              }
            } catch (e) {
              console.warn("Not able to show the register citation form ", e);
            }
          }

          if (MetacatUI.appModel.get("displayDatasetViewMetric")) {
            const viewsMetricView = new MetricView({
              metricName: "Views",
              model: metricsModel,
              pid: this.pid,
            });
            buttonToolbar.append(viewsMetricView.render().el);
            this.subviews.push(viewsMetricView);
          }
        }
      },

      /**
       * Check if the DataPackage provenance parsing has completed. If it has,
       * draw provenance charts. If it hasn't start the parseProv function.
       * The view must have the DataPackage collection set as view.dataPackage
       * for this function to run.
       */
      checkForProv() {
        if (!this.dataPackage) {
          return;
        }
        // Render the provenance trace using the redrawProvCharts function instead of the drawProvCharts function
        // just in case the prov charts have already been inserted. Redraw will make sure they are removed
        // before being re-inserted.
        const { model } = this;
        if (this.dataPackage.provenanceFlag == "complete") {
          this.redrawProvCharts(this.dataPackage);
        } else {
          this.listenToOnce(this.dataPackage, "queryComplete", function () {
            this.redrawProvCharts(this.dataPackage);
          });
          // parseProv triggers "queryComplete"
          this.dataPackage.parseProv();
        }
      },

      /*
       * Renders ProvChartViews on the page to display provenance on a package level and on an individual object level.
       * This function looks at four sources for the provenance - the package sources, the package derivations, member sources, and member derivations
       */
      drawProvCharts(dataPackage) {
        // Set a listener to re-draw the prov charts when needed
        this.stopListening(this.dataPackage, "redrawProvCharts");
        this.listenToOnce(
          this.dataPackage,
          "redrawProvCharts",
          this.redrawProvCharts,
        );

        // Provenance has to be retrieved from the Package Model (getProvTrace()) before the charts can be drawn
        if (dataPackage.provenanceFlag != "complete") return false;

        // If the user is authorized to edit the provenance for this package
        // then turn on editing, so that edit icons are displayed.
        let editModeOn =
          this.dataPackage.packageModel.get("isAuthorized_write");

        // If this content is archived, then turn edit mode off
        if (this.model.get("archived")) {
          editModeOn = false;
        }

        // If none of the models in this package have the formatId attributes,
        // we should fetch the DataPackage since it likely has only had a shallow fetch so far
        const formats = _.compact(dataPackage.pluck("formatId"));

        // If the number of formatIds is less than the number of models in this collection,
        // then we need to get them.
        if (formats.length < dataPackage.length) {
          let modelsToMerge = [];

          // Get the PackageModel associated with this view
          if (this.packageModels.length) {
            // Get the PackageModel for this DataPackage
            const packageModel = _.find(
              this.packageModels,
              (packageModel) => packageModel.get("id") == dataPackage.id,
            );

            // Merge the SolrResult models into the DataONEObject models
            if (packageModel && packageModel.get("members").length) {
              modelsToMerge = packageModel.get("members");
            }
          }

          // If there is at least one model to merge into this data package, do so
          if (modelsToMerge.length) {
            dataPackage.mergeModels(modelsToMerge);
          }
          // If there are no models to merge in, get them from the index
          else {
            // Listen to the DataPackage fetch to complete and re-execute this function
            this.listenToOnce(dataPackage, "complete", function () {
              this.drawProvCharts(dataPackage);
            });

            // Create a query that searches for all the members of this DataPackage in Solr
            dataPackage.solrResults.currentquery = `${dataPackage.filterModel.getQuery()}%20AND%20-formatType:METADATA`;
            dataPackage.solrResults.fields = "id,seriesId,formatId,fileName";
            dataPackage.solrResults.rows = dataPackage.length;
            dataPackage.solrResults.sort = null;
            dataPackage.solrResults.start = 0;
            dataPackage.solrResults.facet = [];
            dataPackage.solrResults.stats = null;

            // Fetch the data package with the "fromIndex" option
            dataPackage.fetch({ fromIndex: true });

            // Exit this function since it will be executed again when the fetch is complete
            return;
          }
        }

        var view = this;
        // Draw two flow charts to represent the sources and derivations at a package level
        const packageSources = dataPackage.sourcePackages;
        const packageDerivations = dataPackage.derivationPackages;

        if (Object.keys(packageSources).length) {
          const sourceProvChart = new ProvChart({
            sources: packageSources,
            context: dataPackage,
            contextEl: this.$(this.articleContainer),
            dataPackage,
            parentView: view,
          });
          this.subviews.push(sourceProvChart);
          this.$(this.articleContainer).before(sourceProvChart.render().el);
        }
        if (Object.keys(packageDerivations).length) {
          const derivationProvChart = new ProvChart({
            derivations: packageDerivations,
            context: dataPackage,
            contextEl: this.$(this.articleContainer),
            dataPackage,
            parentView: view,
          });
          this.subviews.push(derivationProvChart);
          this.$(this.articleContainer).after(derivationProvChart.render().el);
        }

        if (
          dataPackage.sources.length ||
          dataPackage.derivations.length ||
          editModeOn
        ) {
          // Draw the provenance charts for each member of this package at an object level
          _.each(dataPackage.toArray(), (member, i) => {
            // Don't draw prov charts for metadata objects.
            if (
              member.get("type").toLowerCase() == "metadata" ||
              member.get("formatType").toLowerCase() == "metadata"
            ) {
              return;
            }
            const entityDetailsSection =
              view.findEntityDetailsContainer(member);

            if (!entityDetailsSection) {
              return;
            }

            // Retrieve the sources and derivations for this member
            const memberSources = member.get("provSources") || new Array();
            const memberDerivations =
              member.get("provDerivations") || new Array();

            // Make the source chart for this member.
            // If edit is on, then either a 'blank' sources ProvChart will be displayed if there
            // are no sources for this member, or edit icons will be displayed with prov icons.
            if (memberSources.length || editModeOn) {
              const memberSourcesProvChart = new ProvChart({
                sources: memberSources,
                context: member,
                contextEl: entityDetailsSection,
                dataPackage,
                parentView: view,
                editModeOn,
                editorType: "sources",
              });
              view.subviews.push(memberSourcesProvChart);
              $(entityDetailsSection).before(
                memberSourcesProvChart.render().el,
              );
              view.$(view.articleContainer).addClass("gutters");
            }

            // Make the derivation chart for this member
            // If edit is on, then either a 'blank' derivations ProvChart will be displayed if there,
            // are no derivations for this member or edit icons will be displayed with prov icons.
            if (memberDerivations.length || editModeOn) {
              const memberDerivationsProvChart = new ProvChart({
                derivations: memberDerivations,
                context: member,
                contextEl: entityDetailsSection,
                dataPackage,
                parentView: view,
                editModeOn,
                editorType: "derivations",
              });
              view.subviews.push(memberDerivationsProvChart);
              $(entityDetailsSection).after(
                memberDerivationsProvChart.render().el,
              );
              view.$(view.articleContainer).addClass("gutters");
            }
          });
        }

        // Make all of the prov chart nodes look different based on id
        if (this.$(".prov-chart").length > 10000) {
          const allNodes = this.$(".prov-chart .node");
          let ids = [];
          var view = this;
          let i = 1;

          $(allNodes).each(function () {
            ids.push($(this).attr("data-id"));
          });
          ids = _.uniq(ids);

          _.each(ids, (id) => {
            const matchingNodes = view
              .$(`.prov-chart .node[data-id='${id}']`)
              .not(".editorNode");
            // var matchingEntityDetails = view.findEntityDetailsContainer(id);

            // Don't use the unique class on images since they will look a lot different anyway by their image
            if (!$(matchingNodes).first().hasClass("image")) {
              const className = `uniqueNode${i}`;

              // Add the unique class and up the iterator
              if (matchingNodes.prop("tagName") != "polygon")
                $(matchingNodes).addClass(className);
              else
                $(matchingNodes).attr(
                  "class",
                  `${$(matchingNodes).attr("class")} ${className}`,
                );

              /*  if(matchingEntityDetails)
                    $(matchingEntityDetails).addClass(className); */

              // Save this id->class mapping in this view
              view.classMap.push({
                id,
                className,
              });
              i++;
            }
          });
        }
      },

      /* Step through all prov charts and re-render each one that has been
           marked for re-rendering.
        */
      redrawProvCharts() {
        const view = this;

        // Check if prov edits are active and turn on the prov save bar if so.
        // Alternatively, turn off save bar if there are no prov edits, which
        // could occur if a user undoes a previous which could result in
        // an empty edit list.
        if (this.dataPackage.provEditsPending()) {
          this.showEditorControls();
        } else {
          this.hideEditorControls();

          // Reset the edited flag for each package member
          _.each(this.dataPackage.toArray(), (item) => {
            item.selectedInEditor == false;
          });
        }
        _.each(this.subviews, (thisView, i) => {
          // Check if this is a ProvChartView
          if (
            thisView.className &&
            thisView.className.indexOf("prov-chart") !== -1
          ) {
            // Check if this ProvChartView is marked for re-rendering
            // Erase the current ProvChartView
            thisView.onClose();
          }
        });

        // Remove prov charts from the array of subviews.
        this.subviews = _.filter(
          this.subviews,
          (item) =>
            item.className && item.className.indexOf("prov-chart") == -1,
        );

        view.drawProvCharts(this.dataPackage);
      },

      /*
       * When the data package collection saves successfully, tell the user
       */
      saveSuccess(savedObject) {
        // We only want to perform these actions after the package saves
        if (savedObject.type != "DataPackage") return;

        // Change the URL to the new id
        MetacatUI.uiRouter.navigate(
          `view/${this.dataPackage.packageModel.get("id")}`,
          { trigger: false, replace: true },
        );

        const message = $(document.createElement("div")).append(
          $(document.createElement("span")).text(
            "Your changes have been saved. ",
          ),
        );

        MetacatUI.appView.showAlert(message, "alert-success", "body", 4000, {
          remove: false,
        });

        // Reset the state to clean
        this.dataPackage.packageModel.set("changed", false);

        // If provenance relationships were updated, then reset the edit list now.
        if (this.dataPackage.provEdits.length) this.dataPackage.provEdits = [];

        this.saveProvPending = false;
        this.hideSaving();
        this.stopListening(this.dataPackage, "errorSaving", this.saveError);

        // Turn off "save" footer
        this.hideEditorControls();

        // Update the metadata table header with the new resource map id.
        // First find the DataPackageView for the top level package, and
        // then re-render it with the update resmap id.
        const view = this;
        const metadataId = this.packageModels[0].getMetadata().get("id");
        _.each(this.subviews, (thisView, i) => {
          // Check if this is a ProvChartView
          if (thisView.type && thisView.type.indexOf("DataPackage") !== -1) {
            if (thisView.currentlyViewing == metadataId) {
              const packageId = view.dataPackage.packageModel.get("id");
              const title = packageId
                ? `<span class="subtle">Package: ${packageId}</span>`
                : "";
              thisView.title = `Files in this dataset ${title}`;
              thisView.render();
            }
          }
        });
      },

      /*
       * When the data package collection fails to save, tell the user
       */
      saveError(errorMsg) {
        const errorId = `error${Math.round(Math.random() * 100)}`;
        const message = $(document.createElement("div")).append(
          "<p>Your changes could not be saved.</p>",
        );

        message.append(
          $(document.createElement("a"))
            .text("See details")
            .attr("data-toggle", "collapse")
            .attr("data-target", `#${errorId}`)
            .addClass("pointer"),
          $(document.createElement("div"))
            .addClass("collapse")
            .attr("id", errorId)
            .append($(document.createElement("pre")).text(errorMsg)),
        );

        MetacatUI.appView.showAlert(message, "alert-error", "body", null, {
          emailBody: `Error message: Data Package save error: ${errorMsg}`,
          remove: true,
        });

        this.saveProvPending = false;
        this.hideSaving();
        this.stopListening(this.dataPackage, "successSaving", this.saveSuccess);

        // Turn off "save" footer
        this.hideEditorControls();
      },

      /* If provenance relationships have been modified by the provenance editor (in ProvChartView), then
        update the ORE Resource Map and save it to the server.
        */
      saveProv() {
        // Only call this function once per save operation.
        if (this.saveProvPending) return;

        const view = this;
        if (this.dataPackage.provEditsPending()) {
          this.saveProvPending = true;
          // If the Data Package failed saving, display an error message
          this.listenToOnce(this.dataPackage, "errorSaving", this.saveError);
          // Listen for when the package has been successfully saved
          this.listenToOnce(
            this.dataPackage,
            "successSaving",
            this.saveSuccess,
          );
          this.showSaving();
          this.dataPackage.saveProv();
        } else {
          // TODO: should a dialog be displayed saying that no prov edits were made?
        }
      },

      showSaving() {
        // Change the style of the save button
        this.$("#save-metadata-prov")
          .html('<i class="icon icon-spinner icon-spin"></i> Saving...')
          .addClass("btn-disabled");

        this.$("input, textarea, select, button").prop("disabled", true);
      },

      hideSaving() {
        this.$("input, textarea, select, button").prop("disabled", false);

        // When prov is saved, revert the Save button back to normal
        this.$("#save-metadata-prov").html("Save").removeClass("btn-disabled");
      },

      showEditorControls() {
        this.$("#editor-footer").slideDown();
      },

      hideEditorControls() {
        this.$("#editor-footer").slideUp();
      },

      getEntityNames(packageModels) {
        const viewRef = this;

        _.each(packageModels, (packageModel) => {
          // Don't get entity names for larger packages - users must put the names in the system metadata
          if (packageModel.get("members").length > 100) return;

          // If this package has a different metadata doc than the one we are currently viewing
          const metadataModel = packageModel.getMetadata();
          if (!metadataModel) return;

          if (metadataModel.get("id") != viewRef.pid) {
            const requestSettings = {
              url:
                MetacatUI.appModel.get("viewServiceUrl") +
                encodeURIComponent(metadataModel.get("id")),
              success(parsedMetadata, response, xhr) {
                _.each(packageModel.get("members"), (solrResult, i) => {
                  let entityName = "";

                  if (solrResult.get("formatType") == "METADATA")
                    entityName = solrResult.get("title");

                  const container = viewRef.findEntityDetailsContainer(
                    solrResult,
                    parsedMetadata,
                  );
                  if (container) entityName = viewRef.getEntityName(container);

                  // Set the entity name
                  if (entityName) {
                    solrResult.set("fileName", entityName);
                    // Update the UI with the new name
                    viewRef
                      .$(
                        `.entity-name-placeholder[data-id='${solrResult.get(
                          "id",
                        )}']`,
                      )
                      .text(entityName);
                  }
                });
              },
            };

            $.ajax(
              _.extend(
                requestSettings,
                MetacatUI.appUserModel.createAjaxSettings(),
              ),
            );

            return;
          }

          _.each(packageModel.get("members"), (solrResult, i) => {
            let entityName = "";

            if (solrResult.get("fileName"))
              entityName = solrResult.get("fileName");
            else if (solrResult.get("formatType") == "METADATA")
              entityName = solrResult.get("title");
            else if (solrResult.get("formatType") == "RESOURCE") return;
            else {
              const container = viewRef.findEntityDetailsContainer(solrResult);

              if (container && container.length > 0)
                entityName = viewRef.getEntityName(container);
              else entityName = null;
            }

            // Set the entityName, even if it's null
            solrResult.set("fileName", entityName);
          });
        });
      },

      getEntityName(containerEl) {
        if (!containerEl) return false;

        let entityName = $(containerEl)
          .find(".entityName")
          .attr("data-entity-name");
        if (typeof entityName === "undefined" || !entityName) {
          entityName = $(containerEl)
            .find(".control-label:contains('Entity Name') + .controls-well")
            .text();
          if (typeof entityName === "undefined" || !entityName)
            entityName = null;
        }

        return entityName;
      },

      // Checks if the metadata has entity details sections
      hasEntityDetails() {
        return this.$(".entitydetails").length > 0;
      },

      /**
       * Finds the element in the rendered metadata that describes the given data entity.
       * @param {(DataONEObject|SolrResult|string)} model - Either a model that represents the data object or the identifier of the data object
       * @param {Element} [el] - The DOM element to exclusivly search inside.
       * @returns {Element} - The DOM element that describbbes the given data entity.
       */
      findEntityDetailsContainer(model, el) {
        if (!el) var { el } = this;

        // Get the id and file name for this data object
        let id = "";
        let fileName = "";

        // If a model is given, get the id and file name from the object
        if (
          model &&
          (DataONEObject.prototype.isPrototypeOf(model) ||
            SolrResult.prototype.isPrototypeOf(model))
        ) {
          id = model.get("id");
          fileName = model.get("fileName");
        }
        // If a string is given instead, it must be the id of the data object
        else if (typeof model === "string") {
          id = model;
        }
        // Otherwise, there isn't enough info to find the element, so exit
        else {
          return;
        }

        // If we already found it earlier, return it now
        let container = this.$(
          `.entitydetails[data-id='${id}'], ` +
            `.entitydetails[data-id='${DataONEObject.prototype.getXMLSafeID(
              id,
            )}']`,
        );
        if (container.length) {
          // Store the PID on this element for moreInfo icons
          this.storeEntityPIDs(container, id);

          return container;
        }

        // Are we looking for the main object that this MetadataView is displaying?
        if (id == this.pid) {
          if (this.$("#Metadata").length > 0) return this.$("#Metadata");
          return this.el;
        }

        // Metacat 2.4.2 and up will have the Online Distribution Link marked
        let link = this.$(`.entitydetails a[data-pid='${id}']`);

        // Otherwise, try looking for an anchor with the id matching this object's id
        if (!link.length)
          link = $(el).find(`a#${id.replace(/[^A-Za-z0-9]/g, "\\$&")}`);

        // Get metadata index view
        let metadataFromIndex = _.findWhere(this.subviews, {
          type: "MetadataIndex",
        });
        if (typeof metadataFromIndex === "undefined") metadataFromIndex = null;

        // Otherwise, find the Online Distribution Link the hard way
        if (link.length < 1 && !metadataFromIndex)
          link = $(el).find(
            `.control-label:contains('Online Distribution Info') + .controls-well > a[href*='${id.replace(
              /[^A-Za-z0-9]/g,
              "\\$&",
            )}']`,
          );

        if (link.length > 0) {
          // Get the container element
          container = $(link).parents(".entitydetails");

          if (container.length < 1) {
            // backup - find the parent of this link that is a direct child of the form element
            const firstLevelContainer = _.intersection(
              $(link).parents("form").children(),
              $(link).parents(),
            );
            // Find the controls-well inside of that first level container, which is the well that contains info about this data object
            if (firstLevelContainer.length > 0)
              container = $(firstLevelContainer).children(".controls-well");

            if (container.length < 1 && firstLevelContainer.length > 0)
              container = firstLevelContainer;

            $(container).addClass("entitydetails");
          }

          // Add the id so we can easily find it later
          container.attr("data-id", id);

          // Store the PID on this element for moreInfo icons
          this.storeEntityPIDs(container, id);

          return container;
        }

        // ----Find by file name rather than id-----
        if (!fileName) {
          // Get the name of the object first
          for (var i = 0; i < this.packageModels.length; i++) {
            var model = _.findWhere(this.packageModels[i].get("members"), {
              id,
            });
            if (model) {
              fileName = model.get("fileName");
              break;
            }
          }
        }

        if (fileName) {
          const possibleLocations = [
            `.entitydetails [data-object-name='${fileName}']`,
            `.entitydetails .control-label:contains('Object Name') + .controls-well:contains('${fileName}')`,
            `.entitydetails .control-label:contains('Entity Name') + .controls-well:contains('${fileName}')`,
          ];

          // Search through each possible location in the DOM where the file name might be
          for (var i = 0; i < possibleLocations.length; i++) {
            // Get the elements in this view that match the possible location
            const matches = this.$(possibleLocations[i]);

            // If exactly one match is found
            if (matches.length == 1) {
              // Get the entity details parent element
              container = $(matches).parents(".entitydetails").first();
              // Set the object ID on the element for easier locating later
              container.attr("data-id", id);
              if (container.length) break;
            }
          }

          if (container.length) {
            // Store the PID on this element for moreInfo icons
            this.storeEntityPIDs(container, id);

            return container;
          }
        }

        // --- The last option:----
        // If this package has only one item, we can assume the only entity details are about that item
        const members = this.packageModels[0].get("members");
        const dataMembers = _.filter(
          members,
          (m) => m.get("formatType") == "DATA",
        );
        if (dataMembers.length == 1) {
          if (this.$(".entitydetails").length == 1) {
            this.$(".entitydetails").attr("data-id", id);
            // Store the PID on this element for moreInfo icons
            this.storeEntityPIDs(this.$(".entitydetails"), id);

            return this.$(".entitydetails");
          }
        }

        return false;
      },

      /*
       * Inserts new image elements into the DOM via the image template. Use for displaying images that are part of this metadata's resource map.
       */
      insertDataDetails() {
        // If there is a metadataIndex subview, render from there.
        const metadataFromIndex = _.findWhere(this.subviews, {
          type: "MetadataIndex",
        });
        if (typeof metadataFromIndex !== "undefined") {
          _.each(this.packageModels, (packageModel) => {
            metadataFromIndex.insertDataDetails(packageModel);
          });
          return;
        }

        const viewRef = this;

        _.each(this.packageModels, (packageModel) => {
          const dataDisplay = "";
          const images = [];
          const other = [];
          const packageMembers = packageModel.get("members");

          // Don't do this for large packages
          if (packageMembers.length > 150) return;

          //= === Loop over each visual object and create a dataDisplay template for it to attach to the DOM ====
          _.each(packageMembers, (solrResult, i) => {
            // Don't display any info about nested packages
            if (solrResult.type == "Package") return;

            const objID = solrResult.get("id");

            if (objID == viewRef.pid) return;

            // Is this a visual object (image)?
            const type =
              solrResult.type == "SolrResult"
                ? solrResult.getType()
                : "Data set";
            if (type == "image") images.push(solrResult);

            // Find the part of the HTML Metadata view that describes this data object
            const anchor = $(document.createElement("a")).attr(
              "id",
              objID.replace(/[^A-Za-z0-9]/g, "-"),
            );
            const container = viewRef.findEntityDetailsContainer(objID);

            const downloadButton = new DownloadButtonView({
              model: solrResult,
            });
            downloadButton.render();

            // Insert the data display HTML and the anchor tag to mark this spot on the page
            if (container) {
              // Only show data displays for images hosted on the same origin
              if (type == "image") {
                // Create the data display HTML
                const dataDisplay = $.parseHTML(
                  viewRef
                    .dataDisplayTemplate({
                      type,
                      src: solrResult.get("url"),
                      objID,
                    })
                    .trim(),
                );

                // Insert into the page
                if ($(container).children("label").length > 0)
                  $(container).children("label").first().after(dataDisplay);
                else $(container).prepend(dataDisplay);

                // If this image is private, we need to load it via an XHR request
                if (!solrResult.get("isPublic")) {
                  // Create an XHR
                  const xhr = new XMLHttpRequest();
                  xhr.withCredentials = true;

                  xhr.onload = function () {
                    if (xhr.response)
                      $(dataDisplay)
                        .find("img")
                        .attr("src", window.URL.createObjectURL(xhr.response));
                  };

                  // Open and send the request with the user's auth token
                  xhr.open("GET", solrResult.get("url"));
                  xhr.responseType = "blob";
                  xhr.setRequestHeader(
                    "Authorization",
                    `Bearer ${MetacatUI.appUserModel.get("token")}`,
                  );
                  xhr.send();
                }
              }

              $(container).prepend(anchor);

              const nameLabel = $(container).find(
                "label:contains('Entity Name')",
              );
              if (nameLabel.length) {
                $(nameLabel).parent().after(downloadButton.el);
              }
            }
          });

          //= === Initialize the fancybox images =====
          // We will be checking every half-second if all the HTML has been loaded into the DOM - once they are all loaded, we can initialize the lightbox functionality.
          const numImages = images.length;
          // The shared lightbox options for both images
          const lightboxOptions = {
            prevEffect: "elastic",
            nextEffect: "elastic",
            closeEffect: "elastic",
            openEffect: "elastic",
            aspectRatio: true,
            closeClick: true,
            afterLoad() {
              // Create a custom HTML caption based on data stored in the DOM element
              viewRef.title = `${viewRef.title} <a href='${viewRef.href}' class='btn' target='_blank'>Download</a> `;
            },
            helpers: {
              title: {
                type: "outside",
              },
            },
          };

          if (numImages > 0) {
            let numImgChecks = 0; // Keep track of how many interval checks we have so we don't wait forever for images to load
            const lightboxImgSelector =
              "a[class^='fancybox'][data-fancybox-type='image']";

            // Add additional options for images
            const imgLightboxOptions = lightboxOptions;
            imgLightboxOptions.type = "image";
            imgLightboxOptions.perload = 1;

            const initializeImgLightboxes = function () {
              numImgChecks++;

              // Initialize what images have loaded so far after 5 seconds
              if (numImgChecks == 10) {
                $(lightboxImgSelector).fancybox(imgLightboxOptions);
              }
              // When 15 seconds have passed, stop checking so we don't blow up the browser
              else if (numImgChecks > 30) {
                $(lightboxImgSelector).fancybox(imgLightboxOptions);
                window.clearInterval(imgIntervalID);
                return;
              }

              // Are all of our images loaded yet?
              if (viewRef.$(lightboxImgSelector).length < numImages) return;

              // Initialize our lightboxes
              $(lightboxImgSelector).fancybox(imgLightboxOptions);

              // We're done - clear the interval
              window.clearInterval(imgIntervalID);
            };

            var imgIntervalID = window.setInterval(
              initializeImgLightboxes,
              500,
            );
          }
        });
      },

      replaceEcoGridLinks() {
        const viewRef = this;

        // Find the element in the DOM housing the ecogrid link
        $("a:contains('ecogrid://')").each((i, thisLink) => {
          // Get the link text
          const linkText = $(thisLink).text();

          // Clean up the link text
          const withoutPrefix = linkText.substring(
            linkText.indexOf("ecogrid://") + 10,
          );
          const pid = withoutPrefix.substring(withoutPrefix.indexOf("/") + 1);
          const baseUrl =
            MetacatUI.appModel.get("resolveServiceUrl") ||
            MetacatUI.appModel.get("objectServiceUrl");

          $(thisLink)
            .attr("href", baseUrl + encodeURIComponent(pid))
            .text(pid);
        });
      },

      publish(event) {
        // target may not actually prevent click events, so double check
        const disabled = $(event.target).closest("a").attr("disabled");
        if (disabled) {
          return false;
        }
        const publishServiceUrl = MetacatUI.appModel.get("publishServiceUrl");
        const pid = $(event.target).closest("a").attr("pid");
        const ret = confirm(
          `Are you sure you want to publish ${pid} with a DOI?`,
        );

        if (ret) {
          // show the loading icon
          const message = "Publishing package...this may take a few moments";
          this.showLoading(message);

          let identifier = null;
          const viewRef = this;
          const requestSettings = {
            url: publishServiceUrl + pid,
            type: "PUT",
            xhrFields: {
              withCredentials: true,
            },
            success(data, textStatus, xhr) {
              // the response should have new identifier in it
              identifier = $(data).find("d1\\:identifier, identifier").text();

              if (identifier) {
                viewRef.hideLoading();
                const msg = `Published data package '${identifier}'. If you are not redirected soon, you can view your <a href='${
                  MetacatUI.root
                }/view/${encodeURIComponent(
                  identifier,
                )}'>published data package here</a>`;
                viewRef.$el.find(".container").prepend(
                  viewRef.alertTemplate({
                    msg,
                    classes: "alert-success",
                  }),
                );

                // navigate to the new view after a few seconds
                setTimeout(() => {
                  // avoid a double fade out/in
                  viewRef.$el.html("");
                  viewRef.showLoading();
                  MetacatUI.uiRouter.navigate(`view/${identifier}`, {
                    trigger: true,
                  });
                }, 3000);
              }
            },
            error(xhr, textStatus, errorThrown) {
              // show the error message, but stay on the same page
              const msg = `Publish failed: ${$(xhr.responseText)
                .find("description")
                .text()}`;

              viewRef.hideLoading();
              viewRef.showError(msg);
            },
          };

          $.ajax(
            _.extend(
              requestSettings,
              MetacatUI.appUserModel.createAjaxSettings(),
            ),
          );
        }
      },

      // When the given ID from the URL is a resource map that has no metadata, do the following...
      noMetadata(solrResultModel) {
        this.hideLoading();
        this.$el.html(this.template());

        this.pid =
          solrResultModel.get("resourceMap") || solrResultModel.get("id");

        // Insert breadcrumbs
        this.insertBreadcrumbs();

        this.insertDataSource();

        // Insert a table of contents
        this.insertPackageTable(solrResultModel);

        this.renderMetadataFromIndex();

        // Insert a message that this data is not described by metadata
        MetacatUI.appView.showAlert(
          "Additional information about this data is limited since metadata was not provided by the creator.",
          "alert-warning",
          this.$(this.metadataContainer),
        );
      },

      // this will lookup the latest version of the PID
      showLatestVersion() {
        // If this metadata doc is not obsoleted by a new version, then exit the function
        if (!this.model.get("obsoletedBy")) {
          return;
        }

        const view = this;

        // When the latest version is found,
        this.listenTo(this.model, "change:newestVersion", () => {
          // Make sure it has a newer version, and if so,
          if (view.model.get("newestVersion") != view.model.get("id")) {
            // Put a link to the newest version in the content
            view.$(".newer-version").replaceWith(
              view.versionTemplate({
                pid: view.model.get("newestVersion"),
              }),
            );
          } else {
            view.$(".newer-version").remove();
          }
        });

        // Insert the newest version template with a loading message
        this.$el.prepend(
          this.versionTemplate({
            loading: true,
          }),
        );

        // Find the latest version of this metadata object
        this.model.findLatestVersion();
      },

      showLoading(message) {
        this.hideLoading();

        MetacatUI.appView.scrollToTop();

        const loading = this.loadingTemplate({ msg: message });
        if (!loading) return;

        this.$loading = $($.parseHTML(loading));
        this.$detached = this.$el.children().detach();

        this.$el.html(loading);
      },

      hideLoading() {
        if (this.$loading) this.$loading.remove();
        if (this.$detached) this.$el.html(this.$detached);
      },

      showError(msg) {
        // Remove any existing error messages
        this.$el.children(".alert-container").remove();

        this.$el.prepend(
          this.alertTemplate({
            msg,
            classes: "alert-error",
            containerClasses: "page",
            includeEmail: true,
          }),
        );
      },

      /**
       * When the "Metadata" button in the table is clicked while we are on the Metadata view,
       * we want to scroll to the anchor tag of this data object within the page instead of navigating
       * to the metadata page again, which refreshes the page and re-renders (more loading time)
       * @param e
       */
      previewData(e) {
        // Don't go anywhere yet...
        e.preventDefault();

        // Get the target and id of the click
        let link = $(e.target);
        if (!$(link).hasClass("preview")) link = $(link).parents("a.preview");

        if (link) {
          var id = $(link).attr("data-id");
          if (typeof id === "undefined" || !id) return false; // This will make the app defualt to the child view previewData function
        } else return false;

        // If we are on the Metadata view, update the  URL and scroll to the
        // anchor
        window.location.hash = encodeURIComponent(id);
        MetacatUI.appView.scrollTo(this.findEntityDetailsContainer(id));

        return true;
      },

      /**
       * Try to scroll to the section on a page describing the identifier in the
       * fragment/hash portion of the current page.
       *
       * This function depends on there being an `id` dataset attribute on an
       * element on the page set to an XML-safe version of the value in the
       * fragment/hash. Used to provide direct links to sub-resources on a page.
       */
      scrollToFragment() {
        const { hash } = window.location;

        if (!hash || hash.length <= 1) {
          return;
        }

        // Get the id from the URL hash and decode it
        const idFragment = decodeURIComponent(hash.substring(1));

        // Find the corresponding entity details section for this id
        const entityDetailsEl = this.findEntityDetailsContainer(idFragment);

        if (entityDetailsEl || entityDetailsEl.length) {
          MetacatUI.appView.scrollTo(entityDetailsEl);
        }
      },

      /**
       * Navigate to a new /view URL with a fragment
       *
       * Used in getModel() when the pid originally passed into MetadataView
       * is not a metadata PID but is, instead, a data PID. getModel() does
       * the work of finding an appropriate metadata PID for the data PID and
       * this method handles re-routing to the correct URL.
       * @param {string} metadata_pid - The new metadata PID
       * @param {string} data_pid - Optional. A data PID that's part of the
       *   package metadata_pid exists within.
       */
      navigateWithFragment(metadata_pid, data_pid) {
        let next_route = `view/${encodeURIComponent(metadata_pid)}`;

        if (typeof data_pid === "string" && data_pid.length > 0) {
          next_route += `#${encodeURIComponent(data_pid)}`;
        }

        MetacatUI.uiRouter.navigate(next_route, { trigger: true });
      },

      closePopovers(e) {
        // If this is a popover element or an element that has a popover, don't close anything.
        // Check with the .classList attribute to account for SVG elements
        const svg = $(e.target).parents("svg");

        if (
          _.contains(e.target.classList, "popover-this") ||
          $(e.target).parents(".popover-this").length > 0 ||
          $(e.target).parents(".popover").length > 0 ||
          _.contains(e.target.classList, "popover") ||
          (svg.length && _.contains(svg[0].classList, "popover-this"))
        )
          return;

        // Close all active popovers
        this.$(".popover-this.active").popover("hide");
      },

      highlightNode(e) {
        // Find the id
        let id = $(e.target).attr("data-id");

        if (typeof id === "undefined" || !id)
          id = $(e.target).parents("[data-id]").attr("data-id");

        // If there is no id, return
        if (typeof id === "undefined") return false;

        // Highlight its node
        $(`.prov-chart .node[data-id='${id}']`).toggleClass("active");

        // Highlight its metadata section
        if (MetacatUI.appModel.get("pid") == id)
          this.$("#Metadata").toggleClass("active");
        else {
          const entityDetails = this.findEntityDetailsContainer(id);
          if (entityDetails) entityDetails.toggleClass("active");
        }
      },

      onClose() {
        const viewRef = this;

        this.stopListening();

        _.each(this.subviews, (subview) => {
          if (subview.onClose) subview.onClose();
        });

        this.packageModels = new Array();
        this.model.set(this.model.defaults);
        this.pid = null;
        this.dataPackage = null;
        this.seriesId = null;
        this.$detached = null;
        this.$loading = null;

        // Put the document title back to the default
        MetacatUI.appModel.resetTitle();

        // Remove view-specific classes
        this.$el.removeClass("container no-stylesheet");

        this.$el.empty();
      },

      /**
       * Generate a string appropriate to go into the author/creator portion of
       * a dataset citation from the value stored in the underlying model's
       * origin field.
       */
      getAuthorText() {
        const authors = this.model.get("origin");
        let count = 0;
        let authorText = "";

        _.each(authors, (author) => {
          count++;

          if (count == 6) {
            authorText += ", et al. ";
            return;
          }
          if (count > 6) {
            return;
          }

          if (count > 1) {
            if (authors.length > 2) {
              authorText += ",";
            }

            if (count == authors.length) {
              authorText += " and";
            }

            if (authors.length > 1) {
              authorText += " ";
            }
          }

          authorText += author;
        });

        return authorText;
      },

      /**
       * Generate a string appropriate to be used in the publisher portion of a
       * dataset citation. This method falls back to the node ID when the proper
       * node name cannot be fetched from the app's NodeModel instance.
       */
      getPublisherText() {
        const datasource = this.model.get("datasource");
        const memberNode = MetacatUI.nodeModel.getMember(datasource);

        if (memberNode) {
          return memberNode.name;
        }
        return datasource;
      },

      /**
       * Generate a string appropriate to be used as the publication date in a
       * dataset citation.
       */
      getDatePublishedText() {
        // Dataset/datePublished
        // Prefer pubDate, fall back to dateUploaded so we have something to show
        if (this.model.get("pubDate") !== "") {
          return this.model.get("pubDate");
        }
        return this.model.get("dateUploaded");
      },

      /**
       * Generate Schema.org-compliant JSONLD for the model bound to the view into
       *  the head tag of the page by `insertJSONLD`.
       *
       * Note: `insertJSONLD` should be called to do the actual inserting into the
       * DOM.
       */
      generateJSONLD() {
        const { model } = this;

        // Determine the path (either #view or view, depending on router
        // configuration) for use in the 'url' property
        const { href } = document.location;
        const route = href
          .replace(`${document.location.origin}/`, "")
          .split("/")[0];

        // First: Create a minimal Schema.org Dataset with just the fields we
        // know will come back from Solr (System Metadata fields).
        // Add the rest in conditional on whether they are present.
        const elJSON = {
          "@context": {
            "@vocab": "https://schema.org/",
          },
          "@type": "Dataset",
          "@id": `https://dataone.org/datasets/${encodeURIComponent(
            model.get("id"),
          )}`,
          datePublished: this.getDatePublishedText(),
          dateModified: model.get("dateModified"),
          publisher: {
            "@type": "Organization",
            name: this.getPublisherText(),
          },
          identifier: this.generateSchemaOrgIdentifier(model.get("id")),
          version: model.get("version"),
          url: `https://dataone.org/datasets/${encodeURIComponent(
            model.get("id"),
          )}`,
          schemaVersion: model.get("formatId"),
          isAccessibleForFree: true,
        };

        // Attempt to add in a sameAs property of we have high confidence the
        // identifier is a DOI
        if (this.model.isDOI(model.get("id"))) {
          const doi = this.getCanonicalDOIIRI(model.get("id"));

          if (doi) {
            elJSON.sameAs = doi;
          }
        }

        // Second: Add in optional fields

        // Name
        if (model.get("title")) {
          elJSON.name = model.get("title");
        }

        // Creator
        if (model.get("origin")) {
          elJSON.creator = model.get("origin").map((creator) => ({
            "@type": "Person",
            name: creator,
          }));
        }

        // Dataset/spatialCoverage
        if (
          model.get("northBoundCoord") &&
          model.get("eastBoundCoord") &&
          model.get("southBoundCoord") &&
          model.get("westBoundCoord")
        ) {
          const spatialCoverage = {
            "@type": "Place",
            additionalProperty: [
              {
                "@type": "PropertyValue",
                additionalType:
                  "http://dbpedia.org/resource/Coordinate_reference_system",
                name: "Coordinate Reference System",
                value: "http://www.opengis.net/def/crs/OGC/1.3/CRS84",
              },
            ],
            geo: this.generateSchemaOrgGeo(
              model.get("northBoundCoord"),
              model.get("eastBoundCoord"),
              model.get("southBoundCoord"),
              model.get("westBoundCoord"),
            ),
            subjectOf: {
              "@type": "CreativeWork",
              fileFormat: "application/vnd.geo+json",
              text: this.generateGeoJSONString(
                model.get("northBoundCoord"),
                model.get("eastBoundCoord"),
                model.get("southBoundCoord"),
                model.get("westBoundCoord"),
              ),
            },
          };

          elJSON.spatialCoverage = spatialCoverage;
        }

        // Dataset/temporalCoverage
        if (model.get("beginDate") && !model.get("endDate")) {
          elJSON.temporalCoverage = model.get("beginDate");
        } else if (model.get("beginDate") && model.get("endDate")) {
          elJSON.temporalCoverage = `${model.get("beginDate")}/${model.get("endDate")}`;
        }

        // Dataset/variableMeasured
        if (model.get("attributeName")) {
          elJSON.variableMeasured = model.get("attributeName");
        }

        // Dataset/description
        if (model.get("abstract")) {
          elJSON.description = model.get("abstract");
        } else {
          const datasets_url = `https://dataone.org/datasets/${encodeURIComponent(
            model.get("id"),
          )}`;
          elJSON.description = `No description is available. Visit ${datasets_url} for complete metadata about this dataset.`;
        }

        // Dataset/keywords
        if (model.get("keywords")) {
          elJSON.keywords = model.get("keywords").join(", ");
        }

        return elJSON;
      },

      /**
       * Insert Schema.org-compliant JSONLD for the model bound to the view into
       * the head tag of the page (at the end).
       * @param {object} json - JSON-LD to insert into the page
       *
       * Some notes:
       *
       * - Checks if the JSONLD already exists from the previous data view
       * - If not create a new script tag and append otherwise replace the text
       *   for the script
       */
      insertJSONLD(json) {
        if (!document.getElementById("jsonld")) {
          const el = document.createElement("script");
          el.type = "application/ld+json";
          el.id = "jsonld";
          el.text = JSON.stringify(json);
          document.querySelector("head").appendChild(el);
        } else {
          const script = document.getElementById("jsonld");
          script.text = JSON.stringify(json);
        }
      },

      /**
       * Generate a Schema.org/identifier from the model's id
       *
       * Tries to use the PropertyValue pattern when the identifier is a DOI
       * and falls back to a Text value otherwise
       * @param {string} identifier - The raw identifier
       */
      generateSchemaOrgIdentifier(identifier) {
        if (!this.model.isDOI()) {
          return identifier;
        }

        const doi = this.getCanonicalDOIIRI(identifier);

        if (!doi) {
          return identifier;
        }

        return {
          "@type": "PropertyValue",
          propertyID: "https://registry.identifiers.org/registry/doi",
          value: doi.replace("https://doi.org/", "doi:"),
          url: doi,
        };
      },

      /**
       * Generate a Schema.org/Place/geo from bounding coordinates
       *
       * Either generates a GeoCoordinates (when the north and east coords are
       * the same) or a GeoShape otherwise.
       * @param north
       * @param east
       * @param south
       * @param west
       */
      generateSchemaOrgGeo(north, east, south, west) {
        if (north === south) {
          return {
            "@type": "GeoCoordinates",
            latitude: north,
            longitude: west,
          };
        }
        return {
          "@type": "GeoShape",
          box: `${west}, ${south} ${east}, ${north}`,
        };
      },

      /**
       * Creates a (hopefully) valid geoJSON string from the a set of bounding
       * coordinates from the Solr index (north, east, south, west).
       *
       * This function produces either a GeoJSON Point or Polygon depending on
       * whether the north and south bounding coordinates are the same.
       *
       * Part of the reason for factoring this out, in addition to code
       * organization issues, is that the GeoJSON spec requires us to modify
       * the raw result from Solr when the coverage crosses -180W which is common
       * for datasets that cross the Pacific Ocean. In this case, We need to
       * convert the east bounding coordinate from degrees west to degrees east.
       *
       * e.g., if the east bounding coordinate is 120 W and west bounding
       * coordinate is 140 E, geoJSON requires we specify 140 E as 220
       * @param {number} north - North bounding coordinate
       * @param {number} east - East bounding coordinate
       * @param {number} south - South bounding coordinate
       * @param {number} west - West bounding coordinate
       */
      generateGeoJSONString(north, east, south, west) {
        if (north === south) {
          return this.generateGeoJSONPoint(north, east);
        }
        return this.generateGeoJSONPolygon(north, east, south, west);
      },

      /**
       * Generate a GeoJSON Point object
       * @param {number} north - North bounding coordinate
       * @param {number} east - East bounding coordinate
       *
       * Example:
       * {
       *  "type": "Point",
       *  "coordinates": [
       *      -105.01621,
       *      39.57422
       * ]}
       */
      generateGeoJSONPoint(north, east) {
        const preamble = '{"type":"Point","coordinates":';
        const inner = `[${east},${north}]`;
        const postamble = "}";

        return preamble + inner + postamble;
      },

      /**
       * Generate a GeoJSON Polygon object from
       * @param {number} north - North bounding coordinate
       * @param {number} east - East bounding coordinate
       * @param {number} south - South bounding coordinate
       * @param {number} west - West bounding coordinate
       *
       *
       * Example:
       *
       * {
       *   "type": "Polygon",
       *   "coordinates": [[
       *     [ 100, 0 ],
       *     [ 101, 0 ],
       *     [ 101, 1 ],
       *     [ 100, 1 ],
       *     [ 100, 0 ]
       * ]}
       */
      generateGeoJSONPolygon(north, east, south, west) {
        const preamble =
          '{"type":"Feature","properties":{},"geometry":{"type":"Polygon","coordinates":[[';

        // Handle the case when the polygon wraps across the 180W/180E boundary
        if (east < west) {
          east = 360 - east;
        }

        const inner =
          `[${west},${south}],` +
          `[${east},${south}],` +
          `[${east},${north}],` +
          `[${west},${north}],` +
          `[${west},${south}]`;

        const postamble = "]]}}";

        return preamble + inner + postamble;
      },

      /**
       * Create a canonical IRI for a DOI given a random DataONE identifier.
       * @param {string} identifier: The identifier to (possibly) create the IRI
       *   for.
       * @param identifier
       * @returns {string|null} Returns null when matching the identifier to a DOI
       *   regex fails or a string when the match is successful
       *
       * Useful for describing resources identified by DOIs in linked open data
       * contexts or possibly also useful for comparing two DOIs for equality.
       *
       * Note: Really could be generalized to more identifier schemes.
       */
      getCanonicalDOIIRI(identifier) {
        return MetacatUI.appModel.DOItoURL(identifier) || null;
      },

      /**
       * Insert citation information as meta tags into the head of the page
       *
       * Currently supports Highwire Press style tags (citation_) which is
       * supposedly what Google (Scholar), Mendeley, and Zotero support.
       */
      insertCitationMetaTags() {
        // Generate template data to use for all templates
        const title = this.model.get("title");
        const authors = this.model.get("origin");
        const publisher = this.getPublisherText();
        const date = new Date(this.getDatePublishedText())
          .getUTCFullYear()
          .toString();
        const isDOI = this.model.isDOI(this.model.get("id"));
        const id = this.model.get("id");
        const abstract = this.model.get("abstract");

        // Generate HTML strings from each template
        const hwpt = this.metaTagsHighwirePressTemplate({
          title,
          authors,
          publisher,
          date,
          isDOI,
          id,
          abstract,
        });

        // Clear any that are already in the document.
        $("meta[name='citation_title']").remove();
        $("meta[name='citation_authors']").remove();
        $("meta[name='citation_author']").remove();
        $("meta[name='citation_publisher']").remove();
        $("meta[name='citation_date']").remove();
        $("meta[name='citation_doi']").remove();
        $("meta[name='citation_abstract']").remove();

        // Insert
        document.head.insertAdjacentHTML("beforeend", hwpt);

        // Update Zotero
        // https://www.zotero.org/support/dev/exposing_metadata#force_zotero_to_refresh_metadata
        document.dispatchEvent(
          new Event("ZoteroItemUpdated", {
            bubbles: true,
            cancelable: true,
          }),
        );
      },

      createAnnotationViews() {
        try {
          const viewRef = this;

          _.each($(".annotation"), (annoEl) => {
            const newView = new AnnotationView({
              el: annoEl,
            });
            viewRef.subviews.push(newView);
          });
        } catch (e) {
          console.error(e);
        }
      },

      insertMarkdownViews() {
        const viewRef = this;

        _.each($(".markdown"), (markdownEl) => {
          const newView = new MarkdownView({
            markdown: $(markdownEl).text().trim(),
            el: $(markdownEl).parent(),
          });

          viewRef.subviews.push(newView);

          // Clear out old content before rendering
          $(markdownEl).remove();

          newView.render();
        });
      },

      storeEntityPIDs(entityEl, entityId) {
        let entityPID = entityId;
        // Get the entity ID if it is null or undefined
        if (entityPID == null) entityPID = $(entityEl).data("id");

        // Perform clean up with the entity ID
        if (entityPID && typeof entityPID === "string") {
          // Check and replace urn-uuid- with urn:uuid: if the string starts with urn-uuid-
          if (entityPID.startsWith("urn-uuid-")) {
            entityPID = entityPID.replace("urn-uuid-", "urn:uuid:");
          }

          // Check and replace doi-10. with doi:10. if the string starts with doi-10.
          if (entityPID.startsWith("doi-10.")) {
            entityPID = entityPID.replace("doi-10.", "doi:10.");
          }
        }

        if (!this.entities.includes(entityPID)) {
          this.entities.push(entityPID);
        }
      },
    },
  );

  return MetadataView;
});