Source: src/js/views/MetadataView.js

define([
  "jquery",
  "jqueryui",
  "underscore",
  "backbone",
  "models/dataPackage/DataPackage",
  "models/SolrResult",
  "models/CitationModel",
  "models/dataONEServices/ObjectService",
  "models/dataONEServices/SysMetaService",
  "models/dataPackage/DataPackageRecovery",
  "models/dataPackage/UploadRecoveryStore",
  "common/QueryService",
  "common/DateUtilities",
  "common/ErrorUtilities",
  "common/Utilities",
  "common/ValueUtilities",
  "common/UrlUtilities",
  "models/fileTable/DataPackageFileTableAdapter",
  "models/fileTable/FileTableMetrics",
  "views/fileTable/FileTableView",
  "views/CitationHeaderView",
  "views/CanonicalDatasetHandlerView",
  "views/metadataView/DatasetControlsView",
  "views/VersionNavigationView",
  "views/MetadataDocumentView",
  "text!templates/metadata/metadata.html",
  "text!templates/dataSource.html",
  "text!templates/newerVersion.html",
  "text!templates/loading.html",
  "text!templates/alert.html",
  "text!templates/metaTagsHighwirePress.html",
], (
  $,
  $ui,
  _,
  Backbone,
  DataPackage,
  SolrResult,
  CitationModel,
  ObjectService,
  SysMetaService,
  DataPackageRecovery,
  UploadRecoveryStore,
  QueryService,
  DateUtilities,
  ErrorUtilities,
  Utilities,
  ValueUtilities,
  UrlUtilities,
  DataPackageFileTableAdapter,
  FileTableMetrics,
  FileTableView,
  CitationHeaderView,
  CanonicalDatasetHandlerView,
  ControlsView,
  VersionNavigationView,
  MetadataDocumentView,
  MetadataTemplate,
  DataSourceTemplate,
  VersionTemplate,
  LoadingTemplate,
  AlertTemplate,
  metaTagsHighwirePressTemplate,
) => {
  "use strict";

  const { isAbortError } = ErrorUtilities;
  const { MESSAGES: FILE_TABLE_MESSAGES } = DataPackageFileTableAdapter;

  const CLASS_NAMES = {
    alertError: "alert-error",
    alert: "alert",
    alertContainer: "alert-container",
    alertInfo: "alert-info",
    alertSuccess: "alert-success",
    alertWarning: "alert-warning",
    archived: "archived",
    btnDisabled: "btn-disabled",
    collapse: "collapse",
    container: "container",
    dataSource: "data-source",
    datasetInfoIconsContainer: "dataset-info-icons-container",
    fileListingNoteAction: "file-listing-note-action",
    fileListingNoteStatus: "file-listing-note-status",
    fileTableContainer: "file-table-container",
    hasDataSource: "has-data-source",
    hasMoreBelow: "has-more-below",
    danger: "danger",
    icon: "icon",
    iconCircle: "icon icon-circle icon-stack-base",
    iconCircleBase: "icon icon-circle icon-stack-base",
    iconLevelUp: "icon icon-on-left icon-level-up",
    iconLock: "icon-lock",
    iconLockTop: "icon icon-lock icon-stack-top",
    iconStackBase: "icon-stack",
    iconStack: "icon-stack private tooltip-this",
    iconStackTop: "icon-stack-top",
    tooltipThis: "tooltip-this",
    iconTrash: "icon-trash",
    loading: "loading",
    noStylesheet: "no-stylesheet",
    parentLink: "parent-link",
    pointer: "pointer",
    private: "private",
    saveSpinner: "icon icon-spinner icon-spin",
    VERSION_NAVIGATION_CONTAINER: "top-info",
  };

  const FILE_LISTING_STATES = {
    ambiguous: "ambiguousListing",
    limited: "limitedListing",
    newerVersionUnavailable: "newerVersionUnavailable",
    permissionUnavailable: "permissionUnavailable",
    recoverable: "recoverableLimitedListing",
    serverUnavailable: "serverUnavailable",
  };

  const FINISH_INTERRUPTED_SAVE_ACTION = "finish-interrupted-save";

  const MESSAGES = {
    archivedDataset: "This dataset has been archived.",
    back: "Back",
    backToSearch: " Back to search",
    chooseOneToView: " Choose one to view:",
    chooseFileListToView: " Choose a file list to view:",
    downloadFailed: "The download failed. Please try again.",
    errorRenderingMetadataView(error) {
      return `Error rendering metadata view. ${error?.message || String(error)}`;
    },
    fileCount(count) {
      return `${count} ${count === 1 ? "file" : "files"}`;
    },
    fileDetailsMayBeUnavailable: "Some file details may be unavailable.",
    fileMembersLimited(shown, total) {
      return `Showing the first ${shown} of ${total} files. ${MESSAGES.fileDetailsMayBeUnavailable}`;
    },
    filesAndFoldersColumn: "Files and Folders",
    fileListingNotices: {
      [FILE_LISTING_STATES.ambiguous]:
        "More than one file listing is associated with this dataset. Only the metadata document is shown.",
      [FILE_LISTING_STATES.limited]:
        "A full file listing is not available for this dataset.",
      [FILE_LISTING_STATES.newerVersionUnavailable]:
        "A newer file list exists but is not accessible. Showing the newest file list you can access.",
      [FILE_LISTING_STATES.permissionUnavailable]:
        "Permission is required to view the file list for this dataset. Log-in with an account that has access to see the complete file listing.",
      // Public-facing note for an incomplete listing without implying the dataset is broken.
      [FILE_LISTING_STATES.recoverable]:
        "Additional files may appear once processing is complete.",
      [FILE_LISTING_STATES.serverUnavailable]:
        "Some package details could not be loaded. The file list below may be incomplete.",
    },
    fileListingRecoveryFailed(message) {
      return ` We couldn't finish the interrupted save${
        message ? `: ${message}` : ""
      }. Please contact the support team.`;
    },
    fileListingRecoveryFinished: " Save finished. Reloading...",
    fileListingRecoveryNotFinished:
      " We couldn't finish the interrupted save. Please contact the support team.",
    fileListingRecoveryRunning:
      " Finishing interrupted save. This may take a moment...",
    finishInterruptedSave: "Finish interrupted save",
    filesInDatasetTitle(fileCountLabel) {
      return `${fileCountLabel} in this dataset`;
    },
    indexing:
      "This metadata document is being indexed. Please try again in a few minutes.",
    limitedMetadata:
      "Limited information is available for this dataset because it was not described with metadata.",
    loading: "Loading...",
    loadingDatasetDetails: "Loading dataset details...",
    metadataBreadcrumb: "Metadata",
    multipleDocumentingDatasetsHtml(dataPid, items) {
      return (
        `<h4>This data file is described by more than one dataset.</h4>` +
        `<p>The file '${Utilities.encodeHTML(dataPid)}' belongs to multiple ` +
        `datasets.${items ? MESSAGES.chooseOneToView : ""}</p>${
          items ? `<ul>${items}</ul>` : ""
        }`
      );
    },
    multipleResourceMapsHtml(items) {
      return (
        `<h4>This dataset has more than one file list.</h4>` +
        `<p>The metadata is included in multiple data packages with different ` +
        `file lists.${items ? MESSAGES.chooseFileListToView : ""}</p>` +
        `${items ? `<ul>${items}</ul>` : ""}`
      );
    },
    noChangesToSave: "There are no changes to save.",
    noFilesToDisplay: "No files to display yet.",
    notFoundHtml(id) {
      return (
        `<h4>Nothing was found.</h4>` +
        `<p id='metadata-view-not-found-message'>The dataset identifier '${Utilities.encodeHTML(
          id,
        )}' does not exist or it may have been removed. <a>Search for ` +
        `datasets that mention ${Utilities.encodeHTML(id)}</a></p>`
      );
    },
    packageSubtitle(packageId) {
      return packageId ? `Package: ${packageId}` : "";
    },
    parentDataset(label) {
      return `Parent dataset: ${label}`;
    },
    privateDataset: "This is a private dataset.",
    privateDatasetHtml(signInUrl, loggedIn) {
      const signInText = loggedIn
        ? ""
        : ` If you believe you have permission to access this dataset, then <a href="${signInUrl}">sign in</a>.`;
      return (
        `<span class="${CLASS_NAMES.iconStack}" data-toggle="tooltip"` +
        ` data-placement="top" data-container="#metadata-controls-container"` +
        ` title="" data-original-title="${MESSAGES.privateDataset}.">` +
        `<i class="${CLASS_NAMES.iconCircle} ${CLASS_NAMES.private}"></i>` +
        `<i class="${CLASS_NAMES.iconLockTop}"></i>` +
        `</span> ${MESSAGES.privateDataset}${signInText}`
      );
    },
    publishCancelled: "User cancelled publish action",
    publishConfirmation(pid) {
      return `Are you sure you want to publish ${pid} with a DOI?`;
    },
    publishFailed(message) {
      return message.startsWith("Publish failed")
        ? message
        : `Publish failed: ${message}`;
    },
    publishingPackage: "Publishing package...this may take a few moments",
    publishSuccess(newId, newUrl) {
      return `Published data package '${newId}'. If you are not redirected soon,
      you can view your <a href='${newUrl}'>published data package here</a>`;
    },
    publishPending(newId, newUrl) {
      return `Published as <a href="${newUrl}">${newId}</a>. The file list is still being processed and may not be available yet. Please check back later.`;
    },
    resourceMapProvChangesNotSaved:
      "ResourceMap provenance changes were not saved.",
    retrievingDatasetDetails: "Retrieving dataset details...",
    retrievingMetadata: "Retrieving metadata ...",
    save: "Save",
    savedChanges: "Your changes have been saved. ",
    savingHtml: `<i class="${CLASS_NAMES.saveSpinner}"></i> Saving...`,
    search: "Search",
    seeDetails: "See details",
    home: "Home",
    unsavedChangesError: "Your changes could not be saved.",
  };

  /**
   * @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,

      dataPackage: null,
      activeAlert: null,
      renderId: null,
      el: "#Content",
      metadataContainerSelector: "#metadata-container",
      citationContainer: "#citation-container",
      tableContainer: "#table-container",
      breadcrumbContainer: "#breadcrumb-container",
      parentLinkContainer: "#parent-link-container",
      dataSourceContainer: "#data-source-container",
      articleContainer: "#article-container",

      type: "Metadata",

      // Templates
      template: _.template(MetadataTemplate),
      alertTemplate: _.template(AlertTemplate),
      versionTemplate: _.template(VersionTemplate),
      loadingTemplate: _.template(LoadingTemplate),
      dataSourceTemplate: _.template(DataSourceTemplate),
      metaTagsHighwirePressTemplate: _.template(metaTagsHighwirePressTemplate),

      /** @inheritdoc */
      events: {
        "click     .preview": "previewData",
        "click     #save-metadata-prov": "saveProv",
      },

      /**
       * Initialize the MetadataView
       * @param {object} options Object containing the view's options
       * @param {string} [options.pid] The identifier of the metadata object to
       * render
       * @param {string} [options.el] The jQuery selector for the element in
       * which to render the view
       */
      initialize(options = {}) {
        this.pid =
          options.pid || options.id || MetacatUI.appModel.get("pid") || null;
        this.dataPackage = null;
        if (typeof options.el !== "undefined") this.setElement(options.el);
      },

      /** @inheritdoc */
      async render(options = {}) {
        const { renderId, signal: renderSignal } = this.startRender();
        if (options && Object.prototype.hasOwnProperty.call(options, "pid")) {
          this.pid = options.pid;
        }
        if (
          options &&
          Object.prototype.hasOwnProperty.call(options, "seriesId")
        ) {
          this.seriesId = options.seriesId || null;
        }
        this.stopListening();
        this.closeFileTableView();

        MetacatUI.appModel.set("headerType", "default");
        this.showLoading(MESSAGES.loading);

        // Reset per-render state.
        this.subviews = [];
        this.metricsModel = null;
        this.fileTableMetricsModel = null;
        this.fileTableMetricsByPid = null;
        this.fileTableMetricsLoading = false;
        this.memberDownloadReadDenied = new Set();
        this.packageDownloadReadDenied = false;
        this.fileTableDetailsLimited = false;
        this.fileTableMemberCount = 0;
        this.packageDownloadUrl = "";
        this.packageDownloadUnavailableReason = "";
        this.saveProvPending = false;

        this.dataPackage = null;
        this.resourceMap = null;
        this.resourceMapEditBlockers = [];
        this.metadata = null;

        // Login changes re-render the view so permissions and private content
        // refresh. The render controller cancels fetch-capable work; stale
        // renderId guards cover legacy async work that cannot be cancelled.
        this.listenTo(MetacatUI.appUserModel, "change:loggedIn", this.render);

        const dataPackage = new DataPackage({
          ...MetacatUI.appModel.getDataPackageServiceOptions(),
          objectFormats: MetacatUI.objectFormats,
        });
        this.dataPackage = dataPackage;
        this.listenTo(dataPackage.events, "load:progress", (progress) => {
          if (!this.isCurrentRender(renderId)) return;
          this.updateDataPackageLoadProgress(progress);
        });
        this.listenTo(dataPackage.events, "load:metadata", ({ metadata }) => {
          if (!this.isCurrentRender(renderId)) return;
          this.startMetadataRender(metadata, { renderId });
        });
        // Show the provenance save footer only when the resource map is
        // editable. Provenance editability follows resource map write access
        // (set by checkProvenanceWritePermission), not metadata write access.
        this.canEditProvenance = false;
        this.listenTo(dataPackage.events, "provenance:changed", () => {
          if (this.canEditProvenance) this.showEditorControls();
        });

        // 1. Resolve the input PID to its package context.
        let result;
        try {
          result = await this.resolveInput(dataPackage, {
            renderId,
            signal: renderSignal,
          });
        } catch (error) {
          if (isAbortError(error) || !this.isCurrentRender(renderId)) {
            return this;
          }
          // Resolution itself failed (e.g. network). Treat as a primary-object
          // retrieval error rather than a missing package.
          this.onModelError(error?.status, error?.message || String(error));
          return this;
        }
        if (!result || !this.isCurrentRender(renderId)) return this;

        // 2. Primary-object failures replace the whole page and take precedence
        // over any package handling.
        if (result.notFound) {
          this.closeMetadataView();
          this.showNotFound();
          return this;
        }
        const resolvedMetadata = dataPackage.getPrimaryMetadataMember();
        const unauthorizedPackage =
          result.unauthorized === true && result.success !== true;
        if (result.isPrivate || (unauthorizedPackage && !resolvedMetadata)) {
          this.closeMetadataView();
          this.showIsPrivate();
          return this;
        }
        if (result.error) {
          this.closeMetadataView();
          this.onModelError(result.error.status, result.error.message);
          return this;
        }
        // 3. A data PID routes to the metadata that documents it (preserving
        // the data PID in the URL fragment), or to a limited view.
        if (result.isData) {
          return this.handleDataInput(result, {
            renderId,
            signal: renderSignal,
          });
        }

        this.metadata =
          resolvedMetadata || dataPackage.getPrimaryMetadataMember();
        if (this.metadata && !this.metadataRenderPromise) {
          this.prepareCitationModel();
          this.renderMetadataShell({
            metadataMessage: MESSAGES.loadingDatasetDetails,
          });
        }

        // 4. Load the package members from the resolved resource map, keeping
        // any failure scoped so it does not block metadata that can render.
        const packageLoad = await this.loadPackageMembers(
          dataPackage,
          unauthorizedPackage,
          { renderId, signal: renderSignal },
        );
        if (!packageLoad || !this.isCurrentRender(renderId)) return this;
        const { packageError } = packageLoad;
        this.resourceMap = packageLoad.resourceMap;
        this.resourceMapEditBlockers = packageLoad.resourceMapEditBlockers;
        this.fileTableDetailsLimited = packageLoad.fileTableDetailsLimited;

        this.metadata = dataPackage.getPrimaryMetadataMember();
        if (
          this.metadataRenderPid &&
          this.metadata?.pid !== this.metadataRenderPid
        ) {
          if (this.metadata) {
            this.startMetadataRender(this.metadata, { renderId });
          } else {
            this.closeMetadataView();
          }
        }
        if (result.isResourceMap && this.metadata?.pid) {
          this.pid = this.metadata.pid;
          MetacatUI.uiRouter.navigate(
            `view/${encodeURIComponent(this.metadata.pid)}`,
            {
              trigger: false,
              replace: true,
            },
          );
        }

        // 5. No identifiable metadata: render a limited, no-metadata view
        // rather than rendering data or a resource map as science metadata.
        if (!this.metadata) {
          if (result.multipleRMs) {
            // Could not resolve a single package and there is no metadata to
            // keep on screen, so show the ambiguity as the page.
            this.showMultipleResourceMaps(result, { scoped: false });
            return this;
          }
          if (result.isIndexing) {
            this.showIndexing();
            return this;
          }
          if (this.resourceMap || result.isResourceMap) {
            this.renderNoMetadata({
              id: this.resourceMap?.pid || result.resolvedPid,
              renderId,
              signal: renderSignal,
            });
            return this;
          }
          this.renderNoMetadata({
            id: result.resolvedPid || this.pid,
            renderId,
            signal: renderSignal,
          });
          return this;
        }

        // 6. Metadata is available. Render it first so package-detail problems
        // stay scoped to the file/package area.
        this.prepareCitationModel();

        await this.checkWritePermissions({ renderId, signal: renderSignal });
        if (!this.isCurrentRender(renderId)) return this;
        await this.checkProvenanceWritePermission({
          renderId,
          signal: renderSignal,
        });
        if (!this.isCurrentRender(renderId)) return this;

        // The viewer loads members without editable state. Initialize it before
        // allowing provenance edits.
        if (this.canEditProvenance) {
          dataPackage.initializeLoadedMembersForEditing();
        }

        await this.renderMetadata({ renderId, signal: renderSignal });
        if (!this.isCurrentRender(renderId)) return this;

        // 7. Package details: unresolved package ambiguity replaces only the
        // file area. Other incomplete listings keep the file table available.
        if (result.multipleRMs) {
          this.showMultipleResourceMaps(result);
        } else {
          const fileListingState = await this.resolveFileListingState(
            result,
            packageError,
            { renderId, signal: renderSignal },
          );
          if (!this.isCurrentRender(renderId)) return this;
          await this.insertPackageTable(this.metadata, {
            renderId,
            signal: renderSignal,
            fileListingState,
          });
        }

        // Need template rendered before we can insert breadcrumbs (i.e. not
        // showing loading message)
        try {
          this.insertBreadcrumbs();
        } catch (error) {
          // Not fatal if breadcrumbs don't render, but log the error for
          // debugging
          console.error("Error inserting breadcrumbs:", error);
        }

        // Show a link to the parent dataset when this package is nested inside
        // another. Non-blocking: it queries for the parent metadata.
        this.insertParentLink({ renderId, signal: renderSignal }).catch(
          (error) => {
            if (isAbortError(error) || !this.isCurrentRender(renderId)) {
              return;
            }
            console.error("Error inserting parent link:", error);
          },
        );

        return this;
      },

      /**
       * Resolve the input PID, retrying without a trailing route section when
       * the full value is not found
       * @param {DataPackage} dataPackage Package used to resolve the input
       * @param {object} [options] Render options
       * @returns {Promise<object|null>} Resolution result, or null when a newer
       * render starts
       * @since 2.39.0
       */
      async resolveInput(dataPackage, options = {}) {
        const { renderId, signal } = this.getRenderOptions(options);
        let result = await dataPackage.resolveFromPid(this.pid, { signal });
        if (!this.isCurrentRender(renderId)) return null;

        const routeSectionMatch =
          result.notFound &&
          typeof this.pid === "string" &&
          this.pid.match(/\?section=[^?]*$/);
        if (!routeSectionMatch || routeSectionMatch.index <= 0) return result;

        this.pid = this.pid.slice(0, routeSectionMatch.index);
        result = await dataPackage.resolveFromPid(this.pid, { signal });
        if (!this.isCurrentRender(renderId)) return null;
        return result;
      },

      /**
       * Load members from the authoritative resource map, then enrich them
       * from the index. Fall back to index membership when the resource map
       * cannot be loaded.
       * @param {DataPackage} dataPackage Package receiving loaded members
       * @param {boolean} unauthorizedPackage Whether resolution was
       * unauthorized
       * @param {object} [options] Render options
       * @returns {Promise<object|null>} Package load state, or null when a newer
       * render starts
       * @since 2.39.0
       */
      async loadPackageMembers(dataPackage, unauthorizedPackage, options = {}) {
        const { renderId, signal } = this.getRenderOptions(options);
        const resourceMap = dataPackage.getRootResourceMapMember();
        let resourceMapEditBlockers = [];
        let packageError = null;
        let indexResult = null;

        if (!resourceMap && unauthorizedPackage) {
          packageError = {
            ok: false,
            reason: "unauthorized",
            details: {
              inputId: dataPackage.inputId,
              rootResourceMapPid: dataPackage.rootResourceMapPid || null,
            },
          };
        }

        if (!resourceMap) {
          return {
            resourceMap,
            resourceMapEditBlockers,
            packageError,
            fileTableDetailsLimited: false,
          };
        }

        const maxMembers = MetacatUI.appModel.get("maxViewerPackageMembers");
        let useIndexFallback = false;
        try {
          const rmResult = await dataPackage.getManifestFromResourceMap({
            signal,
          });
          if (!this.isCurrentRender(renderId)) return null;
          if (rmResult && rmResult.ok === false) {
            packageError = rmResult;
            useIndexFallback = rmResult.reason !== "unauthorized";
          } else {
            indexResult = await dataPackage.getManifestFromIndex({
              onlyExisting: true,
              rows: maxMembers,
              signal,
            });
            if (!this.isCurrentRender(renderId)) return null;
          }
        } catch (error) {
          if (isAbortError(error) || !this.isCurrentRender(renderId)) {
            return null;
          }
          if (error?.code === "resource_map_not_editable") {
            resourceMapEditBlockers = [...(error.issues || [])];
            useIndexFallback = true;
          }
          packageError = {
            ok: false,
            reason: "error",
            httpStatus: error?.status ?? null,
            error,
          };
        }

        if (useIndexFallback) {
          try {
            indexResult = await dataPackage.getManifestFromIndex({
              rows: maxMembers,
              signal,
            });
            if (!this.isCurrentRender(renderId)) return null;
          } catch (error) {
            if (isAbortError(error) || !this.isCurrentRender(renderId)) {
              return null;
            }
            console.warn(
              "Could not load package member details from the index:",
              error,
            );
          }
        }

        const indexDetails = indexResult?.details;
        const fileTableDetailsLimited =
          indexDetails?.total > indexDetails?.count;

        return {
          resourceMap,
          resourceMapEditBlockers,
          packageError,
          fileTableDetailsLimited,
        };
      },

      /**
       * Check whether table specific async work still targets this package.
       * @param {DataPackage} dataPackage DataPackage captured before an await
       * @returns {boolean} True when the package is still active
       * @since 2.39.0
       */
      isCurrentDataPackage(dataPackage) {
        return dataPackage === this.dataPackage;
      },

      /**
       * Start a render and cancel work from the previous render
       * @returns {{renderId:string, signal:AbortSignal}} Render identity
       * @since 2.39.0
       */
      startRender() {
        this.abortRender();
        this.renderId = ValueUtilities.makeUUID({ prefix: "render-" });
        this.renderAbortController = new AbortController();
        return {
          renderId: this.renderId,
          signal: this.renderAbortController.signal,
        };
      },

      /**
       * Check whether asynchronous work belongs to the active render
       * @param {string} renderId Render identifier
       * @returns {boolean} Whether the render is active
       * @since 2.39.0
       */
      isCurrentRender(renderId) {
        return Boolean(renderId && renderId === this.renderId && this.el);
      },

      /**
       * Fill in the active render identifier for child work
       * @param {object} [options] Render options
       * @param {string} [options.renderId] Render identifier
       * @param {AbortSignal} [options.signal] Cancellation signal
       * @returns {object} Render options
       * @since 2.39.0
       */
      getRenderOptions(options = {}) {
        return {
          renderId: options.renderId || this.renderId,
          signal: options.signal,
        };
      },

      /**
       * Cancel the active render and its scheduled index refresh
       * @returns {void}
       * @since 2.39.0
       */
      abortRender() {
        if (this.fileTableIndexRefreshTimer) {
          clearTimeout(this.fileTableIndexRefreshTimer);
          this.fileTableIndexRefreshTimer = null;
        }
        this.closeMetadataView();
        if (!this.renderAbortController) return;
        this.renderAbortController.abort();
        this.renderAbortController = null;
      },

      /**
       * Handle an input PID that resolves to a data object: route to the
       * metadata that documents it, preserving the data PID in the URL
       * fragment, or fall back when the documenting metadata is ambiguous or
       * absent.
       * @param {object} result Resolution result from resolveFromPid
       * @param {object} [options] Render options
       * @returns {Promise<MetadataView>} This view
       * @since 2.39.0
       */
      async handleDataInput(result, options = {}) {
        const renderOptions = this.getRenderOptions(options);
        const { renderId, signal } = renderOptions;
        const dataPid = this.pid;

        // Multiple independent metadata candidates need a metadata choice;
        // Resource Map ambiguity for one candidate needs a Resource Map choice.
        if (result.multipleRMs) {
          if ((result.candidateMetadataPids || []).length > 1) {
            this.showMultipleDocumentingDatasets(result);
          } else {
            this.showMultipleResourceMaps(result, { scoped: false });
          }
          return this;
        }

        // Routing needs authoritative ResourceMap relationships, not optional index enrichment.
        const { dataPackage } = this;
        try {
          await dataPackage.getManifestFromResourceMap({ signal });
        } catch (error) {
          if (isAbortError(error) || !this.isCurrentRender(renderId)) {
            return this;
          }
          this.onModelError(error?.status, error?.message || String(error));
          return this;
        }
        if (!this.isCurrentRender(renderId)) return this;

        const metadata = dataPackage.getPrimaryMetadataMember();
        if (metadata?.pid) {
          // Navigate to the documenting metadata, keeping the data PID in the
          // fragment so the page can scroll to / highlight it.
          this.onClose();
          this.navigateWithFragment(metadata.pid, dataPid);
          return this;
        }

        // Data object with no documenting metadata: limited view using the
        // resource map when present, otherwise the data PID itself.
        this.resourceMap = dataPackage.getRootResourceMapMember();
        const noMetadataOptions = { id: this.resourceMap?.pid || dataPid };
        if (renderId) noMetadataOptions.renderId = renderId;
        if (signal) noMetadataOptions.signal = signal;
        this.renderNoMetadata(noMetadataOptions);
        return this;
      },

      /**
       * Render a limited view for a package that has no identifiable science
       * metadata (a data PID with no documenting metadata, or a resource map
       * with no metadata member). Shows the package files that can be displayed
       * and explains that descriptive metadata is unavailable. Never renders
       * the data object or resource map XML as science metadata.
       * @param {object} [options] Options
       * @param {string} [options.id] Resource map or object PID to anchor the
       * limited view on
       * @param {string} [options.renderId] Active render identifier
       * @param {AbortSignal} [options.signal] Active render signal
       * @returns {MetadataView} This view
       * @since 2.39.0
       */
      renderNoMetadata({ id, renderId, signal } = {}) {
        if (renderId && !this.isCurrentRender(renderId)) return this;
        const { dataPackage } = this;
        if (!dataPackage) return this;

        this.hideLoading();
        this.$el.html(this.template());

        // Represent the package members (files) we do have. When the package
        // has no members yet, fall back to the input object as a single row.
        const fallback = id ? dataPackage.getMember(id) : null;
        Promise.resolve(
          this.insertPackageTable(fallback || null, { renderId, signal }),
        ).catch((error) => {
          if (
            isAbortError(error) ||
            (renderId && !this.isCurrentRender(renderId))
          ) {
            return;
          }
          console.error("Error inserting package table:", error);
        });

        try {
          this.insertBreadcrumbs();
        } catch (error) {
          console.error("Error inserting breadcrumbs:", error);
        }

        // Explain, where the metadata would appear, that it is unavailable.
        const container =
          this.el.querySelector(this.metadataContainerSelector) || this.el;
        this.showViewAlert(
          MESSAGES.limitedMetadata,
          CLASS_NAMES.alertWarning,
          container,
        );

        this.isRendering = false;
        this.trigger("renderComplete");
        return this;
      },

      /**
       * Handle metadata that is linked to multiple unrelated resource maps.
       * Lists each candidate with its upload time and /view/ route.
       * @param {object} result Resolution result from resolveFromPid
       * @param {object} [options] Options
       * @param {boolean} [options.scoped] When true, metadata is already on
       * screen and the warning stays in the file/package area; when false the
       * warning replaces the page
       * @returns {void}
       * @since 2.39.0
       */
      showMultipleResourceMaps(result, { scoped = true } = {}) {
        const candidates = result?.candidateResourceMapPids || [];
        const dates = result?.candidateResourceMapDates || {};
        const items = candidates
          .map((pid) => {
            const uploaded = dates[pid]
              ? DateUtilities.toLocalTimestampWithZone(dates[pid])
              : null;
            const uploadedLabel = uploaded
              ? `Uploaded ${uploaded}`
              : "Upload time unavailable";
            return (
              `<li><a href="${MetacatUI.root}/view/${encodeURIComponent(
                pid,
              )}">${Utilities.encodeHTML(pid)}</a> ` +
              `<span class="muted">${Utilities.encodeHTML(
                uploadedLabel,
              )}</span></li>`
            );
          })
          .join("");
        const msg = MESSAGES.multipleResourceMapsHtml(items);

        this.stopPackageLoading();
        if (scoped) {
          // Keep the rendered metadata; show the failure in the file area only.
          this.$(this.tableContainer)
            .empty()
            .append(
              $(
                `<div class="${CLASS_NAMES.alert} ${CLASS_NAMES.alertWarning}"></div>`,
              ).html(msg),
            );
        } else {
          this.hideLoading();
          this.showError(msg, { classes: CLASS_NAMES.alertWarning });
        }
      },

      /**
       * Handle a data PID documented by more than one independent metadata
       * version chain. Lists each candidate dataset (preserving the data PID in
       * the fragment) rather than selecting one arbitrarily.
       * @param {object} result Resolution result from resolveFromPid
       * @returns {void}
       * @since 2.39.0
       */
      showMultipleDocumentingDatasets(result) {
        const dataPid = this.pid;
        const candidates = result?.candidateMetadataPids || [];
        const items = candidates
          .map(
            (pid) =>
              `<li><a href="${MetacatUI.root}/view/${encodeURIComponent(
                pid,
              )}#${encodeURIComponent(dataPid)}">${Utilities.encodeHTML(
                pid,
              )}</a></li>`,
          )
          .join("");
        const msg = MESSAGES.multipleDocumentingDatasetsHtml(dataPid, items);

        this.hideLoading();
        this.showError(msg, { classes: CLASS_NAMES.alertWarning });
      },

      /**
       * Choose the quiet file listing note for the table header.
       * @param {object} result Resolution result from DataPackageLoader
       * @param {object|null} packageError Resource map / manifest load error
       * @param {object} [options] Render options
       * @returns {Promise<string|null>} File listing state, or null
       * @since 2.39.0
       */
      async resolveFileListingState(
        result = {},
        packageError = null,
        options = {},
      ) {
        if (packageError) {
          return packageError.reason === "unauthorized"
            ? FILE_LISTING_STATES.permissionUnavailable
            : FILE_LISTING_STATES.serverUnavailable;
        }
        if (
          this.canWrite === true &&
          !result?.multipleRMs &&
          (await this.hasRecoverablePackageRecord(options))
        ) {
          return FILE_LISTING_STATES.recoverable;
        }
        if (result?.newerResourceMapUnavailable) {
          return FILE_LISTING_STATES.newerVersionUnavailable;
        }
        if (this.resourceMap) return null;
        if (result?.multipleRMs) return FILE_LISTING_STATES.ambiguous;
        return FILE_LISTING_STATES.limited;
      },

      /**
       * Check whether this viewer has a local interrupted save record.
       * @param {object} [options] Render options
       * @returns {Promise<boolean>} Whether recovery is available
       * @since 2.39.0
       */
      async hasRecoverablePackageRecord(options = {}) {
        const metadataPid = this.metadata?.pid || this.pid;
        if (!metadataPid) return false;

        const { renderId, signal } = this.getRenderOptions(options);
        if (signal?.aborted) return false;

        try {
          const record = await new UploadRecoveryStore().get(metadataPid);
          if (
            signal?.aborted ||
            (renderId && !this.isCurrentRender(renderId))
          ) {
            return false;
          }
          const loadedResourceMapPid = this.resourceMap?.pid;
          return Boolean(
            record &&
              (!loadedResourceMapPid ||
                record.obsoletesRmPid === loadedResourceMapPid),
          );
        } catch {
          return false;
        }
      },

      /**
       * Build the FileTableView notice attributes for a listing state.
       * @param {string|null} state File listing state
       * @returns {object|null} File table notice attributes
       * @since 2.39.0
       */
      getFileListingNotice(state) {
        const messages = [MESSAGES.fileListingNotices[state]].filter(Boolean);
        const maxMembers = MetacatUI.appModel.get("maxViewerPackageMembers");
        if (
          this.dataPackage?.resourceManifestIsFetched === true &&
          this.fileTableMemberCount > maxMembers
        ) {
          messages.push(
            MESSAGES.fileMembersLimited(maxMembers, this.fileTableMemberCount),
          );
        } else if (this.fileTableDetailsLimited) {
          messages.push(MESSAGES.fileDetailsMayBeUnavailable);
        }
        if (!messages.length) return null;

        const notice = { noticeMessage: messages.join(" ") };
        if (state === FILE_LISTING_STATES.recoverable) {
          notice.noticeActionId = FINISH_INTERRUPTED_SAVE_ACTION;
          notice.noticeActionLabel = MESSAGES.finishInterruptedSave;
          notice.noticeActionClassName = "btn btn-primary";
        }
        return notice;
      },

      /**
       * Handle a table level file listing action.
       * @param {string} actionId Action id
       * @returns {void}
       * @since 2.39.0
       */
      handleFileTableNoticeAction(actionId) {
        if (actionId !== FINISH_INTERRUPTED_SAVE_ACTION) return;
        const pid = this.metadata?.pid || this.pid;
        if (pid) this.repairDataset(pid);
      },

      /**
       * Finish an interrupted save by replaying its durable local recovery
       * record, then reload so the recovered package resolves.
       * @param {string} metadataPid Metadata PID with an interrupted save
       * @returns {Promise<void>} Resolves once recovery has been attempted
       * @since 2.39.0
       */
      async repairDataset(metadataPid) {
        const { renderId } = this;
        const button = this.$(`.${CLASS_NAMES.fileListingNoteAction}`);
        const status = this.$(`.${CLASS_NAMES.fileListingNoteStatus}`);
        button.prop("disabled", true);
        status.text(MESSAGES.fileListingRecoveryRunning);
        try {
          const serviceOptions =
            MetacatUI.appModel.getDataPackageServiceOptions();
          const result = await new DataPackageRecovery({
            resolveServiceUrl: MetacatUI.appModel.get("resolveServiceUrl"),
            objectServiceUrl: MetacatUI.appModel.get("objectServiceUrl"),
            objectService: new ObjectService(
              serviceOptions.objectServiceOptions,
            ),
            sysMetaService: new SysMetaService(
              serviceOptions.sysMetaServiceOptions,
            ),
          }).recover(metadataPid, {
            maxConcurrent: serviceOptions.fetchMaxConcurrent,
          });
          if (!this.isCurrentRender(renderId)) return;
          if (result?.recovered) {
            status.text(MESSAGES.fileListingRecoveryFinished);
            window.location.reload();
            return;
          }
          status.text(MESSAGES.fileListingRecoveryNotFinished);
          button.prop("disabled", false);
        } catch (error) {
          if (!this.isCurrentRender(renderId)) return;
          status.text(
            MESSAGES.fileListingRecoveryFailed(
              error?.message || "unknown error",
            ),
          );
          button.prop("disabled", false);
        }
      },

      /**
       * Remove the loading indicator from the file and package area
       * @returns {void}
       * @since 2.39.0
       */
      stopPackageLoading() {
        this.$(this.tableContainer).find(`.${CLASS_NAMES.loading}`).remove();
        this.$("#data-package-container")
          .children(`.${CLASS_NAMES.loading}`)
          .remove();
      },

      /**
       * Determine whether provenance may be edited. Provenance editing requires
       * write access to a current, unarchived resource map; it must not be
       * inferred from metadata write permission.
       * @param {object} [options] Render options
       * @returns {Promise<boolean>} Whether provenance editing is allowed
       * @since 2.39.0
       */
      async checkProvenanceWritePermission(options = {}) {
        const renderOptions = this.getRenderOptions(options);
        const { renderId, signal } = renderOptions;
        const rmArchived = this.resourceMap?.archived === true;
        const { dataPackage } = this;
        this.canEditProvenance = false;
        if (!dataPackage || rmArchived) return false;

        const editBlockers = [
          ...(this.resourceMapEditBlockers || []),
          ...(dataPackage.getResourceMapModel()?.getEditBlockers() || []),
        ];
        if (editBlockers.length) return false;

        let canWriteRM = false;
        try {
          canWriteRM = await dataPackage.checkResourceMapWritePermission({
            refresh: true,
            signal,
          });
        } catch (error) {
          if (isAbortError(error) || !this.isCurrentRender(renderId)) {
            return false;
          }
          console.error("Error checking resource map write permission:", error);
        }
        if (!this.isCurrentRender(renderId)) return false;
        if (canWriteRM !== true) return false;

        const resourceMapPid = dataPackage.getRootResourceMapMember()?.pid;
        if (!resourceMapPid) return false;
        try {
          const [currentResourceMapPid] = await dataPackage
            .getVersionTracker()
            .getLatestVersions([resourceMapPid], { signal });
          if (
            !this.isCurrentRender(renderId) ||
            currentResourceMapPid !== resourceMapPid
          ) {
            return false;
          }
        } catch (error) {
          if (isAbortError(error) || !this.isCurrentRender(renderId)) {
            return false;
          }
          console.error("Error checking current Resource Map version:", error);
          return false;
        }

        this.canEditProvenance = true;
        return this.canEditProvenance;
      },

      /**
       * When there is an error retrieving the metadata from Solr, check the
       * status code. If it's a 404, check if the object exists in the system
       * and is just indexing. If it's a 401, show a message that this dataset
       * is private. For other errors, show a generic error message with the
       * status code and message if they exist.
       * @param {string|number} status The status code of the error
       * @param {string} message The error message
       * @since 2.39.0
       */
      onModelError(status, message) {
        // coerce status to a string for easier comparison
        const strStatus = String(status);
        if (strStatus === "404") {
          this.showIndexingOrNotFound();
        } else if (strStatus === "401") {
          this.showIsPrivate();
        } else {
          let msg = "<h4>Error retrieving metadata.</h4>";
          if (message) {
            msg += `<p>The following error occurred: ${Utilities.encodeHTML(
              message,
            )}</p>`;
          }
          if (status) {
            msg += `<p>Error code: ${Utilities.encodeHTML(strStatus)}</p>`;
          }
          this.hideLoading();
          this.showError(msg);
        }
      },

      /**
       * Prepare the citation/header model from the current metadata member.
       * @returns {void}
       * @since 2.39.0
       */
      prepareCitationModel() {
        if (!this.metadata) return;
        this.metadataSolrResult = new SolrResult({
          ...this.metadata.toJSON(),
          id: this.metadata.pid,
        });
        this.citationModel = new CitationModel();
        this.citationModel.setSourceModel(this.metadataSolrResult);
      },

      /**
       * Refresh the header from the active package metadata
       * @param {DataPackage} [dataPackage] Package to read
       * @param {object} [options] Render options
       * @returns {boolean} Whether the header was refreshed
       * @since 2.39.0
       */
      refreshMetadataHeaderFromPackage(
        dataPackage = this.dataPackage,
        options = {},
      ) {
        const renderOptions = this.getRenderOptions(options);
        const { renderId } = renderOptions;
        if (
          !dataPackage ||
          !this.isCurrentRender(renderId) ||
          !this.isCurrentDataPackage(dataPackage)
        ) {
          return false;
        }

        const metadata = dataPackage.getPrimaryMetadataMember();
        if (!metadata?.pid) return false;
        if (this.metadata?.pid && metadata.pid !== this.metadata.pid) {
          return false;
        }

        this.metadata = metadata;
        const attrs = { ...metadata.toJSON(), id: metadata.pid };
        if (this.metadataSolrResult) {
          this.metadataSolrResult.set(attrs, { silent: true });
        } else {
          this.metadataSolrResult = new SolrResult(attrs);
        }

        const needsCitation = !this.citationModel;
        if (needsCitation) {
          this.citationModel = new CitationModel();
        }
        this.citationModel.setSourceModel(this.metadataSolrResult);
        if (needsCitation) this.insertCitation();

        if (this.controls?.viewModel) {
          this.controls.viewModel.set(
            {
              metadataModel: this.metadataSolrResult,
              hasWritePermission: this.canWrite === true,
            },
            { silent: true },
          );
          this.controls.render();
          this.citationModal = this.controls.citationModal;
          this.metricsModel = this.controls.viewModel.get("metricsModel");
        }

        MetacatUI.appView.schemaOrg.setSchema(
          "Dataset",
          this.metadataSolrResult,
        );
        this.insertCitationMetaTags?.();
        return true;
      },

      /**
       * Check whether the metadata header lacks indexed display fields
       * @param {DataPackage} [dataPackage] Package to inspect
       * @returns {boolean} Whether index metadata is needed
       * @since 2.39.0
       */
      metadataHeaderNeedsIndexRefresh(dataPackage = this.dataPackage) {
        if (!dataPackage) return false;
        const metadata =
          dataPackage.getPrimaryMetadataMember() || this.metadata;
        if (!metadata?.pid) return false;
        const title = Array.isArray(metadata.title)
          ? metadata.title[0]
          : metadata.title;
        const formatId = metadata.getFormatId?.() || metadata.formatId;
        return !title || !formatId;
      },

      /**
       * Refresh a missing metadata title from the index
       * @param {DataPackage} dataPackage Package to update
       * @param {object} [options] Render options
       * @returns {Promise<boolean>} Whether the header was refreshed
       * @since 2.39.0
       */
      async refreshMetadataTitleFromIndex(dataPackage, options = {}) {
        if (!dataPackage) return false;
        const metadata =
          dataPackage.getPrimaryMetadataMember() || this.metadata;
        if (!metadata?.pid) return false;
        const currentTitle = Array.isArray(metadata.title)
          ? metadata.title[0]
          : metadata.title;
        if (currentTitle) return false;

        const { signal } = this.getRenderOptions(options);
        const response = await QueryService.queryWithFetch({
          q: QueryService.getQueryPart("id", metadata.pid),
          fields: ["id", "title", "fileName", "formatId", "formatType"],
          rows: 1,
          archived: true,
          usePost: true,
          signal,
        });
        const [doc] = QueryService.parseResponse(response);
        const title = Array.isArray(doc?.title) ? doc.title[0] : doc?.title;
        if (!title) return false;

        dataPackage.members.add(
          { pid: doc.id || metadata.pid, ...doc },
          { merge: true, onlyExisting: true, sources: ["index"] },
        );
        return this.refreshMetadataHeaderFromPackage(dataPackage, options);
      },

      /**
       * Check whether the package still needs index enrichment
       * @param {DataPackage} [dataPackage] Package to inspect
       * @returns {boolean} Whether index data is still needed
       * @since 2.39.0
       */
      packageNeedsIndexRefresh(dataPackage = this.dataPackage) {
        if (!dataPackage) return false;
        if (this.metadataHeaderNeedsIndexRefresh(dataPackage)) return true;
        if (this.fileTableDetailsLimited) return false;

        return dataPackage.toArray().some((member) => {
          if (!member?.pid || member.isResourceMap?.()) return false;
          if (
            member.sysMeta ||
            member.sysMetaMissing === true ||
            member.sysMetaReadDenied === true
          ) {
            return false;
          }
          const title = Array.isArray(member.title)
            ? member.title[0]
            : member.title;
          return member.isPlaceholder?.() && !member.getFileName?.() && !title;
        });
      },

      /**
       * Render the page shell before the full metadata body is available.
       * @param {object} [options] Shell options
       * @param {string} [options.metadataMessage] Metadata loading message
       * @returns {void}
       * @since 2.39.0
       */
      renderMetadataShell({
        metadataMessage = MESSAGES.retrievingMetadata,
      } = {}) {
        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: MESSAGES.retrievingDatasetDetails,
          }),
        );

        this.metadataContainer = this.el.querySelector(
          this.metadataContainerSelector,
        );

        // Show loading icon in metadata section
        this.metadataContainer.innerHTML = this.loadingTemplate({
          msg: metadataMessage,
        });
        this.insertCitation();
      },

      /**
       * Render the main components of the metadata view. Insert HTML from the
       * view service or fallback to rendering from the index. Insert
       * breadcrumbs, citation, data source logo, metadata controls, and
       * metadata metrics.
       * @param {object} [options] Render options
       * @returns {MetadataView} This view
       */
      renderMetadata(options = {}) {
        const renderOptions = this.getRenderOptions(options);
        const { renderId } = renderOptions;
        const { pid } = this.metadata;
        const renderPromise = this.startMetadataRender(
          this.metadata,
          renderOptions,
        );

        if (!renderPromise) return this;

        renderPromise
          .then((metadataView) => {
            if (
              !metadataView ||
              !this.isCurrentRender(renderId) ||
              this.metadataRenderPid !== pid ||
              this.metadataView !== metadataView
            ) {
              return this;
            }
            return this.modifyMetadataView(renderOptions, metadataView);
          })
          .catch((error) => {
            if (!isAbortError(error) && this.isCurrentRender(renderId)) {
              console.error("Error finalizing metadata view:", error);
            }
          });
        return this;
      },

      /**
       * Start rendering the active metadata document without waiting for the
       * rest of the package to resolve.
       * @param {DataPackageMember} metadata Metadata member to render
       * @param {object} options Render options
       * @param {string} options.renderId Render identifier
       * @returns {Promise<MetadataDocumentView>|null} In-flight render
       * @since 2.39.0
       */
      startMetadataRender(metadata, { renderId }) {
        if (!this.isCurrentRender(renderId)) return null;
        const { pid } = metadata;

        this.metadata = metadata;
        this.prepareCitationModel();
        const indexResults = [{ ...metadata.toJSON(), id: pid }];

        if (this.metadataRenderPid === pid && this.metadataRenderPromise) {
          this.metadataView.indexResults = indexResults;
          this.insertCitation();
          return this.metadataRenderPromise;
        }

        this.closeMetadataView();
        this.metadataRenderPid = pid;
        this.renderMetadataShell();

        this.metadataAbortController = new AbortController();
        const { signal } = this.metadataAbortController;
        const displayState = {
          isPublic: null,
          isAuthorized:
            metadata.isAuthorized ?? metadata.isAuthorized_read ?? null,
        };
        const metadataView = new MetadataDocumentView({
          pid,
          dataPackage: this.dataPackage,
          editModeOn: this.canEditProvenance === true,
          signal,
          indexResults,
          displayState,
        });
        this.metadataView = metadataView;
        const isCurrentMetadataView = () =>
          !signal.aborted &&
          this.isCurrentRender(renderId) &&
          this.metadataRenderPid === pid &&
          this.metadataView === metadataView;

        this.getDataMemberIsPublic(metadata)
          .then((isPublic) => {
            if (isCurrentMetadataView()) {
              displayState.isPublic = isPublic;
            }
          })
          .catch(() => {
            /* leave isPublic unresolved (null) on error */
          });

        const renderPromise = metadataView
          .render()
          .then((view) => {
            const { metadataContainer } = this;
            if (
              !isCurrentMetadataView() ||
              !metadataContainer ||
              !this.el.contains(metadataContainer)
            ) {
              if (!view.isClosed) {
                view.onClose?.();
                view.remove();
              }
              return null;
            }
            metadataContainer.replaceChildren(view.el);
            this.subviews.push(view);
            return view;
          })
          .catch((error) => {
            if (!metadataView.isClosed) {
              metadataView.onClose?.();
              metadataView.remove();
            }
            if (isAbortError(error) || !isCurrentMetadataView()) {
              return null;
            }
            console.error("Error rendering metadata view:", error);
            this.metadataContainer.innerHTML = this.alertTemplate({
              classes: CLASS_NAMES.alertError,
              msg: MESSAGES.errorRenderingMetadataView(error),
            });
            return null;
          });
        this.metadataRenderPromise = renderPromise;
        return renderPromise;
      },

      /**
       * Enhance the rendered metadata document and finish the landing page
       * @param {object} renderOptions Active render options
       * @param {MetadataDocumentView} metadataView Rendered document view
       * @returns {Promise<MetadataView>} This view
       * @since 2.39.0
       */
      async modifyMetadataView(renderOptions, metadataView) {
        const { renderId } = renderOptions;
        metadataView.enhanceWithPackage({
          editModeOn: this.canEditProvenance === true,
        });
        if (this.fileTableView) {
          await this.mergeCurrentFileTableRows(
            this.dataPackage,
            this.fileTableView,
            renderOptions,
          );
          if (
            !this.isCurrentRender(renderId) ||
            this.metadataView !== metadataView
          ) {
            return this;
          }
        }

        // Insert the citation
        this.insertCitation();
        // Insert the data source logo
        this.insertDataSource();
        // is this the latest version? (includes DOI link when needed)
        this.showVersionNavigation(renderOptions).catch((error) => {
          if (isAbortError(error) || !this.isCurrentRender(renderId)) {
            return;
          }
          console.error("Error rendering version navigation:", error);
        });
        // Insert the icons (private, duplicate, archived)
        try {
          await this.renderInfoIcons(renderOptions);
        } catch (error) {
          console.warn("Error rendering info icons:", error);
          // Not fatal if icons don't render, but log the error for debugging
        }
        if (
          !this.isCurrentRender(renderId) ||
          this.metadataView !== metadataView
        ) {
          return this;
        }

        // Insert the metadata controls (edit, cite, etc)
        this.renderControls();
        // Modifies the view to indicate that this is a dataset is essentially
        // a duplicate of another dataset, if applicable
        if (this.canonicalDatasetHandler) {
          this.canonicalDatasetHandler.onClose();
          this.canonicalDatasetHandler.remove();
        }
        this.canonicalDatasetHandler = new CanonicalDatasetHandlerView({
          metadataView: this,
        }).render();
        this.subviews.push(this.canonicalDatasetHandler);

        this.isRendering = false;
        this.trigger("renderComplete");

        // Insert the Linked Data into the header of the page.
        MetacatUI.appView.schemaOrg.setSchema(
          "Dataset",
          this.metadataSolrResult,
        );
        this.insertCitationMetaTags();

        // The metadata body exists now, so fragment links to entity sections can
        // resolve on the initial page load.
        this.scrollToFragment();
        return this;
      },

      /**
       * Render controls for the current metadata object
       * @returns {void}
       * @since 2.39.0
       */
      renderControls() {
        if (!this.metadataSolrResult) return;
        this.controls = new ControlsView({
          pid: this.pid,
          metadataModel: this.metadataSolrResult,
          publishMethod: () => this.publish.call(this),
          hasWritePermission: this.canWrite === true,
          el: this.$("#metadata-controls-container"),
        }).render();
        this.subviews.push(this.controls);
        // Components created by the header buttons view and used by other subviews
        this.citationModal = this.controls.citationModal;
        this.metricsModel = this.controls.viewModel.get("metricsModel");
        if (this.fileTableView) this.loadFileTableMetrics();
      },

      /**
       * Add breadcrumbs to the page to show the user where they are in the app
       */
      insertBreadcrumbs() {
        const container = this.el.querySelector(this.breadcrumbContainer);
        if (!container) return;
        const breadcrumbs = $(document.createElement("ol"))
          .addClass("breadcrumb")
          .append(
            $(document.createElement("li"))
              .addClass("home")
              .append(
                $(document.createElement("a"))
                  .attr("href", MetacatUI.root || "/")
                  .addClass("home")
                  .text(MESSAGES.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"), 10) + 1
                          }`
                        : ""
                    }`,
                  )
                  .addClass("search")
                  .text(MESSAGES.search),
              ),
          )
          .append(
            $(document.createElement("li")).append(
              $(document.createElement("a"))
                .attr(
                  "href",
                  `${MetacatUI.root}/view/${encodeURIComponent(this.pid)}`,
                )
                .addClass("inactive")
                .text(MESSAGES.metadataBreadcrumb),
            ),
          );

        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"), 10) + 1
                    : ""
                }`,
              )
              .attr("title", MESSAGES.back)
              .addClass("back")
              .text(MESSAGES.backToSearch)
              .prepend(
                $(document.createElement("i")).addClass("icon-angle-left"),
              ),
          );
          $(breadcrumbs).find("a.search").addClass("inactive");
        }

        container.appendChild(breadcrumbs[0]);
      },

      /**
       * When the metadata object doesn't exist, display a message to the user
       */
      showNotFound() {
        this.hideLoading();
        const id = this.pid || this.dataPackage?.inputId || "";
        // Construct a message that shows this object doesn't exist
        const msg = MESSAGES.notFoundHtml(id);

        // 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(id)}`,
        );
      },

      /**
       * Render the authoritative resolver state when Solr returns a 404.
       * @since 2.39.0
       */
      async showIndexingOrNotFound() {
        const result = this.dataPackage?.resolutionResult || {};

        if (result.isPrivate || result.unauthorized) {
          this.showIsPrivate();
          return;
        }

        if (result.isIndexing) {
          this.showIndexing();
          return;
        }

        this.showNotFound();
      },

      /**
       * Show that the object exists but is not indexed yet
       * @returns {void}
       * @since 2.39.0
       */
      showIndexing() {
        this.hideLoading();
        this.showError(MESSAGES.indexing, {
          classes: CLASS_NAMES.alertWarning,
        });
      },

      /** 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.stopListening(
            MetacatUI.appUserModel,
            "change:checked",
            this.showIsPrivate,
          );
          this.listenToOnce(
            MetacatUI.appUserModel,
            "change:checked",
            this.showIsPrivate,
          );
          return;
        }
        this.isRendering = false;
        this.trigger("renderComplete");

        const msg = MESSAGES.privateDatasetHtml(
          `${MetacatUI.root}/signin`,
          MetacatUI.appUserModel.get("loggedIn"),
        );

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

        // Show the not found error message
        this.showError(msg);
      },
      /**
       * Convert the current data package members to generic file table rows.
       * @param {object|null} fallbackMember Member to display when the package
       * has no rows yet
       * @returns {object[]} File table rows
       * @since 2.39.0
       */
      getFileTableRows(fallbackMember = null) {
        const { dataPackage } = this;
        if (!dataPackage) return [];

        const activeMembers = dataPackage.members.getActiveMembers();
        const members = activeMembers.length
          ? activeMembers
          : [fallbackMember].filter(Boolean);
        const maxMembers = MetacatUI.appModel.get("maxViewerPackageMembers");
        this.fileTableMemberCount = dataPackage.getContentMembers().length;
        if (!activeMembers.length && fallbackMember) {
          this.fileTableMemberCount =
            fallbackMember === dataPackage.getRootResourceMapMember() ? 0 : 1;
        }
        if (this.fileTableMemberCount > maxMembers) {
          this.fileTableDetailsLimited = true;
        }
        const resolveBaseUrl =
          MetacatUI.appModel.get("resolveServiceUrl") ||
          MetacatUI.appModel.get("objectServiceUrl") ||
          "";
        const packageId = dataPackage.rootResourceMapPid || "";
        const metadataMember = dataPackage.getPrimaryMetadataMember();
        const packageTitle = metadataMember?.title || "";
        const packageServiceUrl =
          MetacatUI.appModel.get("packageServiceUrl") || "";
        const metricsByPid = this.fileTableMetricsByPid || null;

        return DataPackageFileTableAdapter.buildRows(dataPackage, {
          mode: "viewer",
          resolveBaseUrl,
          members,
          maxMembers,
          packageId,
          packageTitle,
          packageServiceUrl,
          packageDownloadUrl: this.packageDownloadUrl,
          packageDownloadUnavailableReason:
            this.packageDownloadUnavailableReason,
          downloadReadDeniedPids: this.memberDownloadReadDenied,
          formatName: (formatId) => {
            const formatName =
              dataPackage.objectFormats?.getFriendlyFormat?.(formatId);
            return formatName && formatName !== formatId ? formatName : "";
          },
          showMetrics: Boolean(this.metricsModel),
          getRowMetric: metricsByPid
            ? FileTableMetrics.getRowMetric(metricsByPid)
            : null,
        });
      },

      /**
       * Inserts the refactored file table into the metadata view.
       * @param {object|null} fallbackMember Optional member to render when the
       * data package has not been populated
       * @param {object} options File table options
       * @returns {MetadataView} This view
       */
      async insertPackageTable(fallbackMember = null, options = {}) {
        const renderOptions = this.getRenderOptions(options);
        const { renderId } = renderOptions;
        const { dataPackage } = this;
        if (!dataPackage) return this;

        this.confirmPackageDownloadAll(dataPackage);
        const rows = this.getFileTableRows(fallbackMember);
        if (renderId && !this.isCurrentRender(renderId)) return this;
        const packageId = dataPackage.rootResourceMapPid || "";
        const notice = this.getFileListingNotice(options.fileListingState);
        const fileCount = this.fileTableMemberCount;
        const fileCountLabel = MESSAGES.fileCount(fileCount);
        const title =
          options.title || MESSAGES.filesInDatasetTitle(fileCountLabel);
        const subtitle = MESSAGES.packageSubtitle(packageId);

        this.closeFileTableView();

        this.fileTableView = new FileTableView({
          id: packageId || this.pid || "",
          title,
          subtitle,
          fileColumnLabel: MESSAGES.filesAndFoldersColumn,
          rows,
          emptyMessage: MESSAGES.noFilesToDisplay,
          showMetrics: Boolean(this.metricsModel),
          showActions: true,
          showFilteringControl: Boolean(
            MetacatUI.appModel.get("dataPackageFiltering"),
          ),
          showSortingControl: Boolean(
            MetacatUI.appModel.get("dataPackageSorting"),
          ),
          ...(notice || {}),
        });
        const { appModel, nodeModel } = MetacatUI;
        if (appModel.get("isCN") && !nodeModel.get("members").length) {
          const { fileTableView } = this;
          this.listenToOnce(nodeModel, "change:members", () => {
            if (
              this.isCurrentFileTable(dataPackage, fileTableView, renderOptions)
            ) {
              this.confirmPackageDownloadAll(dataPackage);
              this.mergeCurrentFileTableRows(
                dataPackage,
                fileTableView,
                renderOptions,
              );
            }
          });
        }
        this.listenTo(
          this.fileTableView,
          "action:click",
          this.handleFileTableAction,
        );
        this.listenTo(
          this.fileTableView,
          "notice:action",
          this.handleFileTableNoticeAction,
        );
        this.subviews.push(this.fileTableView);

        // Wrap the table in a scrolling container so the sticky header has a
        // scroll ancestor and long packages scroll within a bounded height.
        const fileTableContainer = $(
          `<div class="${CLASS_NAMES.fileTableContainer}"></div>`,
        ).append(this.fileTableView.render().el);
        this.$(this.tableContainer).empty().append(fileTableContainer);
        this.setupFileTableScrollIndicators(fileTableContainer[0]);
        // The file table manages its own tooltips (Fomantic popups), so no
        // Bootstrap tooltip initialization is needed here.

        const packageTableContainer = this.$("#data-package-container");
        $(packageTableContainer).children(`.${CLASS_NAMES.loading}`).remove();

        this.loadNestedPackageTitles(renderOptions);
        this.enrichFileTableMemberDetails(renderOptions);
        this.loadFileTableMetrics();

        return this;
      },

      /**
       * Observe a file table scroll container and update its overflow cue
       * @param {HTMLElement} container Scroll container
       * @returns {void}
       * @since 2.39.0
       */
      setupFileTableScrollIndicators(container) {
        this.teardownFileTableScrollIndicators();
        if (!container) return;

        this.fileTableScrollContainer = container;
        this.fileTableScrollHandler = () => {
          this.scheduleFileTableScrollIndicatorUpdate();
        };
        container.addEventListener("scroll", this.fileTableScrollHandler, {
          passive: true,
        });

        if (typeof ResizeObserver === "function") {
          this.fileTableResizeObserver = new ResizeObserver(() => {
            this.scheduleFileTableScrollIndicatorUpdate();
          });
          this.fileTableResizeObserver.observe(container);
          if (this.fileTableView?.el) {
            this.fileTableResizeObserver.observe(this.fileTableView.el);
          }
        }

        this.updateFileTableScrollIndicators();
      },

      /**
       * Schedule one overflow cue update for the file table
       * @returns {void}
       * @since 2.39.0
       */
      scheduleFileTableScrollIndicatorUpdate() {
        if (!this.fileTableScrollContainer) return;
        if (this.fileTableScrollIndicatorFrame) return;

        if (typeof requestAnimationFrame !== "function") {
          this.updateFileTableScrollIndicators();
          return;
        }

        this.fileTableScrollIndicatorFrame = requestAnimationFrame(() => {
          this.fileTableScrollIndicatorFrame = null;
          this.updateFileTableScrollIndicators();
        });
      },

      /**
       * Update whether the file table has more content below
       * @returns {boolean} Whether more rows are below the viewport
       * @since 2.39.0
       */
      updateFileTableScrollIndicators() {
        const container = this.fileTableScrollContainer;
        if (!container) return false;

        const hasMoreBelow =
          container.scrollTop + container.clientHeight <
          container.scrollHeight - 1;
        container.classList.toggle(CLASS_NAMES.hasMoreBelow, hasMoreBelow);
        return hasMoreBelow;
      },

      /**
       * Remove file table scroll and resize observers
       * @returns {void}
       * @since 2.39.0
       */
      teardownFileTableScrollIndicators() {
        if (
          this.fileTableScrollIndicatorFrame &&
          typeof cancelAnimationFrame === "function"
        ) {
          cancelAnimationFrame(this.fileTableScrollIndicatorFrame);
        }
        this.fileTableScrollIndicatorFrame = null;

        if (this.fileTableResizeObserver) {
          this.fileTableResizeObserver.disconnect();
        }
        this.fileTableResizeObserver = null;

        if (this.fileTableScrollContainer && this.fileTableScrollHandler) {
          this.fileTableScrollContainer.removeEventListener(
            "scroll",
            this.fileTableScrollHandler,
          );
        }
        this.fileTableScrollContainer = null;
        this.fileTableScrollHandler = null;
      },

      /**
       * Set whole package download state before file table rows are built
       * @param {DataPackage} dataPackage Package to inspect
       * @since 2.39.0
       */
      confirmPackageDownloadAll(dataPackage) {
        this.packageDownloadUrl = "";
        this.packageDownloadUnavailableReason = "";
        if (!dataPackage) return;
        const { appModel, nodeModel } = MetacatUI;
        const packageId = dataPackage.rootResourceMapPid || "";
        let packageServiceUrl = appModel.get("packageServiceUrl") || "";
        if (appModel.get("isCN")) {
          const datasource = dataPackage.getRootResourceMapMember()?.datasource;
          const node = nodeModel.getMember(datasource);
          packageServiceUrl =
            node?.readv2 && node.baseURL
              ? appModel.getDataONEMNAPIs(node.baseURL).packageServiceUrl
              : "";
        }
        if (!packageId || !packageServiceUrl) {
          return;
        }

        const members = dataPackage.members.getActiveMembers();
        const memberReadDenied = members.some(
          (member) =>
            member.sysMetaReadDenied === true ||
            this.memberDownloadReadDenied?.has(member.pid),
        );
        // Metacat cannot download a partial package when some members are private:
        // https://github.com/NCEAS/metacat/issues/1169
        if (this.packageDownloadReadDenied || memberReadDenied) {
          this.packageDownloadUnavailableReason =
            FILE_TABLE_MESSAGES.packageDownloadReadDenied;
          return;
        }
        const maxDownloadSize = Number(
          MetacatUI.appModel.get("maxDownloadSize"),
        );
        if (Number.isFinite(maxDownloadSize) && maxDownloadSize > 0) {
          // This download safeguard uses known sizes; missing sizes count as zero.
          if (dataPackage.getTotalSize() > maxDownloadSize) {
            this.packageDownloadUnavailableReason =
              FILE_TABLE_MESSAGES.packageDownloadTooLarge;
            return;
          }
        }

        Object.assign(dataPackage.packageServiceOptions, {
          baseUrl: packageServiceUrl,
        });
        this.packageDownloadUrl =
          packageServiceUrl + encodeURIComponent(packageId);
      },

      /**
       * Check whether asynchronous work belongs to the active file table
       * @param {DataPackage} dataPackage Captured package
       * @param {FileTableView} fileTableView Captured file table
       * @param {object} [options] Render options
       * @returns {boolean} Whether the file table is current
       * @since 2.39.0
       */
      isCurrentFileTable(dataPackage, fileTableView, options = {}) {
        const { renderId, signal } = this.getRenderOptions(options);
        return (
          !signal?.aborted &&
          this.isCurrentRender(renderId) &&
          this.isCurrentDataPackage(dataPackage) &&
          fileTableView === this.fileTableView
        );
      },

      /**
       * Replace rows in the active file table
       * @param {DataPackage} dataPackage Captured package
       * @param {FileTableView} fileTableView Captured file table
       * @param {object} [options] Render options
       * @returns {Promise<boolean>} Whether rows were merged
       * @since 2.39.0
       */
      async mergeCurrentFileTableRows(
        dataPackage,
        fileTableView,
        options = {},
      ) {
        if (!this.isCurrentFileTable(dataPackage, fileTableView, options)) {
          return false;
        }
        const rows = this.getFileTableRows();
        if (!this.isCurrentFileTable(dataPackage, fileTableView, options)) {
          return false;
        }
        fileTableView.viewModel.mergeRows(rows);
        this.scheduleFileTableScrollIndicatorUpdate();
        return true;
      },

      /**
       * Load missing System Metadata used by file table rows
       * @param {object} [options] Render options
       * @returns {Promise<void>} Resolves after enrichment
       * @since 2.39.0
       */
      async enrichFileTableMemberDetails(options = {}) {
        const renderOptions = this.getRenderOptions(options);
        const { signal } = renderOptions;
        const { dataPackage, fileTableView } = this;
        if (this.fileTableDetailsLimited) {
          this.scheduleFileTableIndexRefresh(
            dataPackage,
            fileTableView,
            renderOptions,
          );
          return;
        }
        try {
          const result = await DataPackageFileTableAdapter.enrichMembers(
            dataPackage,
            { signal },
          );
          if (
            !this.isCurrentFileTable(dataPackage, fileTableView, renderOptions)
          ) {
            return;
          }
          this.refreshMetadataHeaderFromPackage(dataPackage, renderOptions);
          this.confirmPackageDownloadAll(dataPackage);
          if (result.changed) {
            await this.mergeCurrentFileTableRows(
              dataPackage,
              fileTableView,
              renderOptions,
            );
          }
          this.scheduleFileTableIndexRefresh(
            dataPackage,
            fileTableView,
            renderOptions,
          );
        } catch (error) {
          if (
            isAbortError(error) ||
            !this.isCurrentFileTable(dataPackage, fileTableView, renderOptions)
          ) {
            return;
          }
          console.warn("File table member details could not be loaded:", error);
        }
      },

      /**
       * Poll the index for package details that are still processing
       * @param {DataPackage} dataPackage Captured package
       * @param {FileTableView} fileTableView Captured file table
       * @param {object} [options] Render options
       * @returns {void}
       * @since 2.39.0
       */
      scheduleFileTableIndexRefresh(dataPackage, fileTableView, options = {}) {
        const renderOptions = this.getRenderOptions(options);
        const { signal } = renderOptions;
        if (this.fileTableIndexRefreshTimer) {
          clearTimeout(this.fileTableIndexRefreshTimer);
        }
        if (!this.packageNeedsIndexRefresh(dataPackage)) {
          this.fileTableIndexRefreshTimer = null;
          return;
        }

        const delays = [2000, 3000, 5000, 8000, 13000, 21000];
        let attempt = 0;
        const tick = async () => {
          this.fileTableIndexRefreshTimer = null;
          if (
            !this.isCurrentFileTable(dataPackage, fileTableView, renderOptions)
          ) {
            return;
          }
          try {
            const metadataTitleRefreshed =
              await this.refreshMetadataTitleFromIndex(
                dataPackage,
                renderOptions,
              );
            if (
              !this.isCurrentFileTable(
                dataPackage,
                fileTableView,
                renderOptions,
              )
            ) {
              return;
            }
            if (this.fileTableDetailsLimited) {
              if (metadataTitleRefreshed) {
                await this.mergeCurrentFileTableRows(
                  dataPackage,
                  fileTableView,
                  renderOptions,
                );
              }
            } else {
              await dataPackage.getManifestFromIndex({
                onlyExisting: true,
                rows: MetacatUI.appModel.get("maxViewerPackageMembers"),
                signal,
              });
              if (
                !this.isCurrentFileTable(
                  dataPackage,
                  fileTableView,
                  renderOptions,
                )
              ) {
                return;
              }
              this.refreshMetadataHeaderFromPackage(dataPackage, renderOptions);
              this.confirmPackageDownloadAll(dataPackage);
              await this.mergeCurrentFileTableRows(
                dataPackage,
                fileTableView,
                renderOptions,
              );
            }
          } catch (error) {
            if (
              isAbortError(error) ||
              !this.isCurrentFileTable(
                dataPackage,
                fileTableView,
                renderOptions,
              )
            ) {
              return;
            }
            console.warn(
              "File table index refresh could not be loaded:",
              error,
            );
          }
          if (!this.packageNeedsIndexRefresh(dataPackage)) return;
          if (attempt >= delays.length) return;
          this.fileTableIndexRefreshTimer = setTimeout(tick, delays[attempt]);
          attempt += 1;
        };

        this.fileTableIndexRefreshTimer = setTimeout(tick, delays[attempt]);
        attempt += 1;
      },

      /**
       * Load dataset titles for nested resource maps from their metadata docs.
       * @param {object} [options] Render options
       * @returns {Promise<void>}
       * @since 2.39.0
       */
      async loadNestedPackageTitles(options = {}) {
        const renderOptions = this.getRenderOptions(options);
        const { signal } = renderOptions;
        const { dataPackage, fileTableView } = this;
        const isCurrentTable = () =>
          this.isCurrentFileTable(dataPackage, fileTableView, renderOptions);

        try {
          const changed = await dataPackage.loadNestedPackageTitles({ signal });
          if (changed && isCurrentTable()) {
            await this.mergeCurrentFileTableRows(
              dataPackage,
              fileTableView,
              renderOptions,
            );
          }
        } catch (error) {
          if (isAbortError(error) || !isCurrentTable()) return;
          console.warn("Nested package titles could not be loaded:", error);
        }
      },

      /**
       * Fetch per PID metrics for the file table in a single grouped request
       * and merge the values into existing rows. Best effort: failures leave
       * metric cells empty.
       * @returns {Promise<void>}
       * @since 2.39.0
       */
      async loadFileTableMetrics() {
        const { dataPackage, fileTableView, metricsModel } = this;
        if (metricsModel !== this.fileTableMetricsModel) {
          if (this.fileTableMetricsModel) {
            this.stopListening(this.fileTableMetricsModel, "sync error");
          }
          this.fileTableMetricsModel = metricsModel;
          this.fileTableMetricsByPid = null;
          this.fileTableMetricsLoading = false;
        }
        if (this.fileTableMetricsByPid || this.fileTableMetricsLoading) return;
        if (!metricsModel) return;
        fileTableView?.viewModel.set("showMetrics", true);
        this.fileTableMetricsLoading = true;
        const isCurrentMetrics = () =>
          this.isCurrentDataPackage(dataPackage) &&
          fileTableView === this.fileTableView &&
          metricsModel === this.metricsModel;

        const updateTableMetrics = () => {
          if (!isCurrentMetrics()) {
            this.fileTableMetricsLoading = false;
            return;
          }
          this.stopListening(metricsModel, "error");
          this.fileTableMetricsByPid = FileTableMetrics.parse(
            metricsModel.toJSON(),
          );
          fileTableView?.viewModel.mergeRows(this.getFileTableRows());
          this.fileTableMetricsLoading = false;
        };

        if (metricsModel.get("synced")) {
          updateTableMetrics();
          return;
        }

        this.listenToOnce(metricsModel, "sync", updateTableMetrics);
        this.listenToOnce(metricsModel, "error", (_model, error) => {
          if (!isCurrentMetrics()) {
            this.fileTableMetricsLoading = false;
            return;
          }
          this.stopListening(metricsModel, "sync");
          this.fileTableMetricsLoading = false;
          console.warn("File table metrics could not be loaded:", error);
        });

        if (!metricsModel.get("fetching")) {
          metricsModel.fetch();
        }
      },

      /**
       * Handle an action emitted by the generic file table.
       * @param {FileItemViewModel} rowModel File table row model
       * @param {FileItemActionViewModel} actionModel Action view model
       * @param {Event} event Click event
       * @returns {boolean|Promise<boolean>} Whether the action was handled
       * @since 2.39.0
       */
      handleFileTableAction(rowModel, actionModel, event) {
        const actionId = actionModel?.get?.("id");

        if (actionId === "preview") {
          return this.previewFileTableRow(rowModel, event);
        }
        if (actionId === "open-dataset") {
          const id = rowModel?.get?.("id");
          if (!id) return false;
          event?.preventDefault?.();
          window.open(
            `${MetacatUI.root}/view/${encodeURIComponent(id)}`,
            "_blank",
          );
          return true;
        }
        if (actionId === "download") {
          return this.downloadFileTableRow(rowModel, actionModel, event);
        }
        return false;
      },

      /**
       * Scroll to the rendered entity section for a file table preview click.
       * @param {FileItemViewModel} rowModel File table row model
       * @param {Event} event Click event
       * @returns {boolean} True when a row was handled
       * @since 2.39.0
       */
      previewFileTableRow(rowModel, event) {
        const id = rowModel?.get?.("id");
        if (!id) return false;

        event?.preventDefault?.();
        window.location.hash = encodeURIComponent(id);
        const entityDetails = this.findEntityDetailsContainer(id);
        if (entityDetails) MetacatUI.appView.scrollTo(entityDetails);
        return true;
      },

      /**
       * Download the package represented by the dataset row.
       * @param {FileItemViewModel} rowModel Dataset row model
       * @param {FileItemActionViewModel} actionModel Download action model
       * @returns {Promise<boolean>} Whether the package could be downloaded
       * @since 2.39.0
       */
      async downloadPackageFileTableRow(rowModel, actionModel) {
        const { dataPackage, fileTableView } = this;
        const { appUserModel } = MetacatUI;
        if (appUserModel.get("checked") && !appUserModel.get("loggedIn")) {
          window.open(rowModel.get("downloadUrl"), "_blank");
          return true;
        }

        actionModel.startPending(
          "Downloading...",
          `Downloading ${rowModel.getDisplayLabel()}`,
        );

        try {
          if (!appUserModel.get("checked")) {
            await new Promise((resolve) => {
              appUserModel.once("change:checked", resolve);
            });
          }
          if (!appUserModel.get("loggedIn")) {
            window.open(rowModel.get("downloadUrl"), "_blank");
            return true;
          }

          const packageId = rowModel.get("pid");
          let blob;
          try {
            blob = await dataPackage.getPackageService().download(packageId);
          } catch (error) {
            if (
              this.dataPackage === dataPackage &&
              this.fileTableView === fileTableView
            ) {
              if (error?.status === 401 || error?.status === 403) {
                if (packageId === dataPackage.rootResourceMapPid) {
                  this.packageDownloadReadDenied = true;
                } else {
                  this.memberDownloadReadDenied.add(packageId);
                }
              } else {
                this.showViewAlert(
                  MESSAGES.downloadFailed,
                  CLASS_NAMES.alertError,
                  "body",
                  null,
                  { remove: true },
                );
              }
            }
            return false;
          }
          const filename = rowModel.get("title") || packageId;
          const downloadFilename = /\.zip$/i.test(filename)
            ? filename
            : `${filename}.zip`;

          if (navigator.msSaveOrOpenBlob) {
            navigator.msSaveOrOpenBlob(blob, downloadFilename);
          } else {
            const link = document.createElement("a");
            const objectUrl = window.URL.createObjectURL(blob);
            try {
              link.href = objectUrl;
              link.download = downloadFilename;
              link.click();
            } finally {
              window.URL.revokeObjectURL(objectUrl);
            }
          }
          return true;
        } finally {
          actionModel.finishPending();
          if (
            this.dataPackage === dataPackage &&
            this.fileTableView === fileTableView &&
            fileTableView?.viewModel
          ) {
            this.confirmPackageDownloadAll(dataPackage);
            fileTableView.viewModel.mergeRows(this.getFileTableRows());
            this.scheduleFileTableScrollIndicatorUpdate();
          }
        }
      },

      /**
       * Download the package or member represented by a file table row.
       * @param {FileItemViewModel} rowModel File table row model
       * @param {FileItemActionViewModel} actionModel Download action model
       * @param {Event} event Click event
       * @returns {Promise<boolean>} Whether the row could be downloaded
       * @since 2.39.0
       */
      async downloadFileTableRow(rowModel, actionModel, event) {
        const id = rowModel?.get?.("id");
        if (!id) return false;

        event?.preventDefault?.();
        const { dataPackage, fileTableView } = this;
        if (!dataPackage || !actionModel.isEnabled()) return false;

        if (rowModel.get("kind") === "dataset" && rowModel.get("downloadUrl")) {
          return this.downloadPackageFileTableRow(rowModel, actionModel);
        }

        const member = dataPackage.getMember(id);
        const rowDownloadUrl = rowModel.get("downloadUrl");
        const downloadModel = this.createDataDetailsModel(member);
        if (downloadModel && !downloadModel.get("url") && rowDownloadUrl) {
          downloadModel.set("url", rowDownloadUrl);
        }
        const downloadUrl = downloadModel?.get("url") || rowDownloadUrl;
        const isPublic = member?.sysMeta
          ? member.sysMeta.accessPolicy?.isPublic() === true
          : member?.indexedIsPublic === true;
        if (isPublic && downloadUrl) {
          window.open(downloadUrl, "_blank");
          return true;
        }

        actionModel.startPending(
          "Downloading...",
          `Downloading ${rowModel.getDisplayLabel()}`,
        );

        try {
          if (typeof downloadModel?.downloadWithCredentials === "function") {
            let downloadFailed = false;
            let downloadError;
            downloadModel.once("downloadError", (error) => {
              downloadFailed = true;
              downloadError = error;
            });
            try {
              await downloadModel.downloadWithCredentials();
            } catch (error) {
              downloadFailed = true;
              downloadError = error;
            }
            if (downloadFailed) {
              if (
                this.dataPackage === dataPackage &&
                this.fileTableView === fileTableView
              ) {
                if (
                  downloadError?.status === 401 ||
                  downloadError?.status === 403
                ) {
                  this.memberDownloadReadDenied.add(id);
                } else {
                  this.showViewAlert(
                    MESSAGES.downloadFailed,
                    CLASS_NAMES.alertError,
                    "body",
                    null,
                    { remove: true },
                  );
                }
              }
              return false;
            }
            return true;
          }

          if (!downloadUrl) return false;
          window.open(downloadUrl, "_blank");
        } finally {
          actionModel.finishPending();
          if (
            this.dataPackage === dataPackage &&
            this.fileTableView === fileTableView &&
            fileTableView?.viewModel
          ) {
            this.confirmPackageDownloadAll(dataPackage);
            fileTableView.viewModel.mergeRows(this.getFileTableRows());
            this.scheduleFileTableScrollIndicatorUpdate();
          }
        }
        return true;
      },

      /**
       * Insert a link to the parent dataset(s) when this package is nested
       * inside another package. The parents are the latest metadata documents
       * aggregated by the resource map(s) that aggregate this package's resource
       * map.
       * @param {object} [options] Render options
       * @returns {Promise<void>}
       */
      async insertParentLink(options = {}) {
        const renderOptions = this.getRenderOptions(options);
        const { renderId, signal } = renderOptions;
        const container = this.el?.querySelector(this.parentLinkContainer);
        if (!container) return;
        container.innerHTML = "";

        const { dataPackage } = this;
        if (!dataPackage) return;
        const rmMember = dataPackage.getRootResourceMapMember();
        const parentResourceMapPids = await this.getParentResourceMapPids(
          rmMember,
          { signal },
        );
        if (!this.isCurrentRender(renderId)) return;
        if (!parentResourceMapPids.length) return;

        const parents = await this.fetchParentPackageMetadata(
          parentResourceMapPids,
          { signal },
        );
        if (!this.isCurrentRender(renderId)) return;
        const selfMetadataPid = this.metadata?.pid;

        parents.forEach((parent) => {
          if (!parent.id || parent.id === selfMetadataPid) return;
          const icon = $(document.createElement("i")).addClass(
            CLASS_NAMES.iconLevelUp,
          );
          const link = $(document.createElement("a"))
            .attr(
              "href",
              `${MetacatUI.root}/view/${encodeURIComponent(parent.id)}`,
            )
            .addClass(CLASS_NAMES.parentLink)
            .text(MESSAGES.parentDataset(parent.title || parent.id))
            .prepend(icon);

          $(container).append(link);
        });
      },

      /**
       * Resolve the resource map PIDs that aggregate this package's resource map
       * (its parents). Prefers the resolved member's `resourceMap` index field
       * and falls back to querying the resource map object directly.
       * @param {DataPackageMember} rmMember Root resource map member
       * @param {object} [options] Options
       * @param {AbortSignal} [options.signal] Active render signal
       * @returns {Promise<string[]>} Parent resource map PIDs, excluding this
       * package's own resource map
       * @since 2.39.0
       */
      async getParentResourceMapPids(rmMember, options = {}) {
        const { signal } = options;
        const selfPid = rmMember?.pid || this.dataPackage?.rootResourceMapPid;
        if (!selfPid) return [];

        // Fast path: the resolved member may already carry the resource map's
        // `resourceMap` index field (the parents that aggregate it).
        const fromMember = this.normalizeResourceMapList(
          rmMember?.resourceMap,
          selfPid,
        );
        if (fromMember.length) return fromMember;

        // Fall back to querying the resource map object's own `resourceMap`
        // field, which lists the parent resource maps that aggregate it.
        try {
          const response = await QueryService.queryWithFetch({
            q: QueryService.getQueryPart("id", selfPid),
            fields: ["resourceMap"],
            rows: 1,
            signal,
          });
          const docs = QueryService.parseResponse(response) || [];
          return this.normalizeResourceMapList(docs[0]?.resourceMap, selfPid);
        } catch (error) {
          if (isAbortError(error)) return [];
          console.error("Error querying for parent resource maps:", error);
          return [];
        }
      },

      /**
       * Normalize a resourceMap relationship value to an array of PIDs,
       * excluding the package's own resource map PID.
       * @param {string|string[]} raw The raw resourceMap value
       * @param {string} selfPid The package's own resource map PID to exclude
       * @returns {string[]} Parent resource map PIDs
       * @since 2.39.0
       */
      normalizeResourceMapList(raw, selfPid) {
        let list = [];
        if (Array.isArray(raw)) list = raw;
        else if (raw) list = [raw];
        return list.filter((pid) => pid && pid !== selfPid);
      },

      /**
       * Query for the latest metadata documents in the given parent resource
       * maps.
       * @param {string[]} parentResourceMapPids Parent resource map PIDs
       * @param {object} [options] Options
       * @param {AbortSignal} [options.signal] Active render signal
       * @returns {Promise<object[]>} Latest parent metadata index docs
       * @since 2.39.0
       */
      async fetchParentPackageMetadata(parentResourceMapPids, options = {}) {
        const { signal } = options;
        const metadataQuery = QueryService.getQueryPart(
          "formatType",
          "METADATA",
        );
        const rmQuery = parentResourceMapPids
          .map((pid) => QueryService.getQueryPart("resourceMap", pid))
          .join(" OR ");

        const response = await QueryService.queryWithFetch({
          q: `${metadataQuery} AND (${rmQuery})`,
          fields: ["id", "title", "obsoletedBy", "resourceMap"],
          rows: 100,
          archived: true,
          usePost: true,
          signal,
        });
        const docs = QueryService.parseResponse(response) || [];

        // Only show the latest metadata in each version chain (i.e. not
        // obsoleted by another document already in the result set).
        const ids = new Set(docs.map((doc) => doc.id));
        return docs.filter(
          (doc) => !doc.obsoletedBy || !ids.has(doc.obsoletedBy),
        );
      },

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

      /** Show a logo for the repository that hosts this metadata */
      insertDataSource() {
        if (
          !this.metadata ||
          !MetacatUI.nodeModel ||
          !MetacatUI.nodeModel.get("members").length ||
          !this.$(this.dataSourceContainer).length
        ) {
          return;
        }

        // const dataSource =
        // MetacatUI.nodeModel.getMember(metadata.datasource);
        const dataSource = MetacatUI.nodeModel.getMember(
          this.metadata.datasource,
        );
        let replicaMNs = MetacatUI.nodeModel.getMembers(
          this.metadata.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.${CLASS_NAMES.dataSource}`).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(CLASS_NAMES.hasDataSource);

          this.$(this.citationContainer).addClass(CLASS_NAMES.hasDataSource);
          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", () => {
              const el = this;
              $(this).popover("show");
              $(".popover").on("mouseleave", () => {
                $(el).popover("hide");
              });
            })
            .on("mouseleave", () => {
              const el = this;
              setTimeout(() => {
                if (!$(".popover:hover").length) {
                  $(el).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.
       * @param {object} [options] Render options
       * @returns {Promise<boolean>} A promise that resolves to true if the user
       * has write permissions, false otherwise.
       */
      async checkWritePermissions(options = {}) {
        const renderOptions = this.getRenderOptions(options);
        const { renderId, signal } = renderOptions;
        const { dataPackage } = this;
        const canWrite = await dataPackage.checkWritePermissions({
          refresh: true,
          signal,
        });
        if (!this.isCurrentRender(renderId)) return false;
        this.canWrite = canWrite;

        this.controls?.viewModel.set("hasWritePermission", this.canWrite);
        return this.canWrite;
      },

      /**
       * Add the info icons to the metadata controls panel. Shows if the dataset
       * is private or archived.
       * @param {object} [options] Render options
       * @returns {Promise<void>} Resolves when public state has been checked
       * @since 2.32.0
       */
      async renderInfoIcons(options = {}) {
        const { renderId } = this.getRenderOptions(options);
        const { metadata } = this;
        const isPublic = await this.getDataMemberIsPublic(metadata);
        if (!this.isCurrentRender(renderId) || this.metadata !== metadata)
          return;
        const isPrivate = isPublic === false;
        const isArchived = metadata?.archived === true;

        if (isPrivate) {
          this.addInfoIcon(
            CLASS_NAMES.private,
            CLASS_NAMES.iconLock,
            CLASS_NAMES.private,
            MESSAGES.privateDataset,
          );
        }
        if (isArchived) {
          this.addInfoIcon(
            CLASS_NAMES.archived,
            CLASS_NAMES.iconTrash,
            CLASS_NAMES.danger,
            MESSAGES.archivedDataset,
          );
        }
      },

      /**
       * Add an info icon to the metadata controls panel.
       * @param {string} iconType - The type of icon to add.
       * @param {string} iconClass - The class
       * @param {string} baseClass - The base class
       * @param {string} titleText - The text to display when the icon is hovered
       * over.
       * @returns {HTMLElement} The icon element that was added to the view.
       * @since 2.32.0
       */
      addInfoIcon(iconType, iconClass, baseClass, titleText) {
        const iconHTML = `<span class="${iconType} ${CLASS_NAMES.icon}">
            <span class="${CLASS_NAMES.iconStackBase} ${iconType} ${CLASS_NAMES.tooltipThis}"
                  data-toggle="tooltip"
                  data-placement="top"
                  data-container="#metadata-controls-container"
                  title="${titleText}">
              <i class="${CLASS_NAMES.iconCircleBase} ${baseClass}"></i>
              <i class="${CLASS_NAMES.icon} ${iconClass} ${CLASS_NAMES.iconStackTop}"></i>
            </span>
          </span>`;

        // Convert the string into DOM element so we can return it
        const range = document.createRange();
        const newIconFragment = range.createContextualFragment(iconHTML);
        const newIcon = newIconFragment.firstChild;

        let iconContainer = this.el.querySelector(
          `${this.citationContainer} .${CLASS_NAMES.datasetInfoIconsContainer}`,
        );
        if (!iconContainer) {
          const title = this.el.querySelector(
            `${this.citationContainer} .title`,
          );
          if (!title) return newIcon;
          iconContainer = document.createElement("span");
          iconContainer.classList.add(CLASS_NAMES.datasetInfoIconsContainer);
          title.append(iconContainer);
        }

        iconContainer.append(newIcon);

        return newIcon;
      },

      /** When the data package collection saves successfully, tell the user */
      saveSuccess() {
        // The saved resource map has a new PID; reflect it in the package
        // state, the URL, and the displayed package identifier.
        const packageId = this.dataPackage.getRootResourceMapMember()?.pid;
        if (packageId) {
          this.dataPackage.rootResourceMapPid = packageId;
          this.resourceMap = this.dataPackage.getRootResourceMapMember();
        }
        MetacatUI.uiRouter.navigate(`view/${packageId}`, {
          trigger: false,
          replace: true,
        });

        const message = $(document.createElement("div")).append(
          $(document.createElement("span")).text(MESSAGES.savedChanges),
        );

        this.showViewAlert(message, CLASS_NAMES.alertSuccess, "body", 4000, {
          remove: true,
        });

        // Clear pending provenance edits. The package's saved state is
        // reconciled by the upload finalization (savedRevision sync).
        this.saveProvPending = false;
        this.hideSaving();

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

        // Rerender the file table so it reflects the new resource map PID.
        this.insertPackageTable();
      },

      /**
       * When the data package collection fails to save, tell the user
       * @param {string} errorMsg The error message to display
       */
      saveError(errorMsg) {
        const errorId = `error${Math.round(Math.random() * 100)}`;
        const message = $(document.createElement("div")).append(
          `<p>${MESSAGES.unsavedChangesError}</p>`,
        );

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

        this.showViewAlert(message, CLASS_NAMES.alertError, "body", null, {
          emailBody: `Error message: Data Package save error: ${errorMsg}`,
          remove: true,
        });

        this.saveProvPending = false;
        this.hideSaving();

        // 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.
       */
      async saveProv() {
        // Only call this function once per save operation.
        if (this.saveProvPending) return;

        const { dataPackage, renderId } = this;
        if (dataPackage.getResourceMapModel()?.hasUnsavedChanges()) {
          this.saveProvPending = true;
          this.showSaving();
          try {
            const result = await dataPackage.upload({
              resourceMapOnly: true,
            });
            if (
              !this.isCurrentRender(renderId) ||
              this.dataPackage !== dataPackage
            ) {
              return;
            }
            if (result.outcome === "success") {
              this.saveSuccess(result);
            } else {
              const details = result.getErrorMessages?.() || [];
              console.warn(
                "ResourceMap provenance changes were not saved:",
                details,
                result,
              );
              this.saveError(
                details.length
                  ? details.join("\n")
                  : MESSAGES.resourceMapProvChangesNotSaved,
              );
            }
          } catch (error) {
            if (
              !this.isCurrentRender(renderId) ||
              this.dataPackage !== dataPackage
            ) {
              return;
            }
            const issueMessages =
              error?.code === "validation_failure"
                ? (error.issues || [])
                    .map((issue) => issue?.message)
                    .filter(Boolean)
                : [];
            this.saveError(
              issueMessages.length
                ? issueMessages.join("\n")
                : error.message || String(error),
            );
          }
        } else {
          this.showViewAlert(
            MESSAGES.noChangesToSave,
            CLASS_NAMES.alertInfo,
            "body",
            4000,
            { remove: true },
          );
        }
      },

      /** Inactivate the save button during the save process */
      showSaving() {
        this.$("#save-metadata-prov")
          .html(MESSAGES.savingHtml)
          .addClass(CLASS_NAMES.btnDisabled);

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

      /** Activate the save button after the save process */
      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(MESSAGES.save)
          .removeClass(CLASS_NAMES.btnDisabled);
      },

      /** Show the editor controls */
      showEditorControls() {
        this.$("#editor-footer").slideDown();
      },

      /** Hide the editor controls */
      hideEditorControls() {
        this.$("#editor-footer").slideUp();
      },

      /**
       * Finds the element in the rendered metadata that describes the given
       * data entity.
       * @param {string|object} model Data member identifier or model
       * @param {Element|jQuery} [containerEl] Container to search within
       * @returns {jQuery|boolean} The entity section or false if it cannot be
       * found
       * @since 2.39.0
       */
      findEntityDetailsContainer(model, containerEl) {
        return (
          this.metadataView?.findEntityDetailsContainer?.(model, containerEl) ||
          false
        );
      },

      /**
       * Get the public read flag for a data member.
       * @param {DataPackageMember} member Package member
       * @returns {boolean|null} Public read state
       * @since 2.39.0
       */
      async getDataMemberIsPublic(member) {
        if (!member) return null;

        if (Object.prototype.hasOwnProperty.call(member, "isPublic")) {
          if (typeof member.isPublic === "boolean") return member.isPublic;
          if (member.isPublic === "true") return true;
          if (member.isPublic === "false") return false;
        }
        if (typeof member.isPublic === "function") {
          return member.isPublic({
            sysMetaService: this.dataPackage?.getSysMetaService(),
          });
        }
        return null;
      },

      /**
       * Adapt a package member for legacy data detail and download views.
       * @param {DataPackageMember} member Package member
       * @returns {object|null} Backbone style data model
       * @since 2.39.0
       */
      createDataDetailsModel(member) {
        if (!member) return null;

        const memberData = member.toJSON();
        const model = new SolrResult({
          ...memberData,
          id: member.pid,
        });
        const url =
          member.url || model.get("url") || member.viewServiceEntity?.objectUrl;
        if (url) model.set("url", url);
        return model;
      },

      /**
       * Publish the current package after user confirmation.
       * @returns {Promise<string>} Published package identifier
       * @throws {Error} When publishing is cancelled or fails
       */
      async publish() {
        // TODO: Replace the browser confirm dialog with a custom modal.
        const confirmationMessage = MESSAGES.publishConfirmation(this.pid);
        // eslint-disable-next-line no-alert
        if (!window.confirm(confirmationMessage)) {
          return Promise.reject(new Error(MESSAGES.publishCancelled));
        }
        this.showLoading(MESSAGES.publishingPackage);

        try {
          const { pid: newId, resourceMapPending } =
            await this.dataPackage.publish();
          const newUrl = UrlUtilities.getViewLink(newId);
          this.hideLoading();

          // just get the view/id part of the URL
          const routerUrl = newUrl.substring(newUrl.indexOf("/view/"));
          if (resourceMapPending) {
            MetacatUI.appView.showAlert(
              MESSAGES.publishPending(newId, newUrl),
              CLASS_NAMES.alertWarning,
              "body",
              15000,
              { remove: true },
            );
          } else {
            const msg = MESSAGES.publishSuccess(newId, newUrl);
            this.$el.prepend(
              this.alertTemplate({
                msg,
                classes: CLASS_NAMES.alertSuccess,
              }),
            );
          }

          setTimeout(() => {
            this.$el.html("");
            this.showLoading();
            MetacatUI.uiRouter.navigate(routerUrl, { trigger: true });
          }, 3000);

          return newId;
        } catch (error) {
          this.hideLoading();
          const message =
            error instanceof Error ? error.message : String(error);
          const formattedMessage = MESSAGES.publishFailed(message);
          this.showError(formattedMessage, { remove: true });
          throw new Error(formattedMessage);
        }
      },

      /**
       * Show an alert at the top of the metadata if there is a newer version of
       * this metadata available
       * @param {string} pid - The PID of the latest version. If null or
       * undefined, the alert will be removed.
       */
      showLatestVersion(pid) {
        // if there is already an alert, remove it.
        if (this.newVersionAlert) this.newVersionAlert.remove();
        const { dataPackage } = this;
        if (!dataPackage) return;
        const metadataPid = dataPackage.getPrimaryMetadataMember()?.pid;
        if (!pid || pid === this.pid || pid === metadataPid) return;
        // insert the template
        const versionAlertHtml = this.versionTemplate({ pid });
        const template = document.createElement("div");
        template.innerHTML = versionAlertHtml;
        this.newVersionAlert = template.firstChild;
        this.el.prepend(this.newVersionAlert);
      },

      /**
       * Show the previous/next version navigation buttons
       * @param {object} [options] Render options
       * @since 2.36.0
       */
      async showVersionNavigation(options = {}) {
        const renderOptions = this.getRenderOptions(options);
        const { renderId, signal } = renderOptions;
        const { dataPackage } = this;
        if (!dataPackage) return;
        const metadataPid = dataPackage.getPrimaryMetadataMember()?.pid;
        if (this.versionNavigation) {
          this.subviews = this.subviews.filter(
            (view) => view !== this.versionNavigation,
          );
          this.versionNavigation.remove();
        }
        const versionNavigation = new VersionNavigationView({
          pid: metadataPid,
          documentType: "dataset",
          signal,
        });
        this.versionNavigation = versionNavigation;
        this.subviews.push(versionNavigation);
        await versionNavigation.render();
        if (!this.isCurrentRender(renderId)) {
          this.subviews = this.subviews.filter(
            (view) => view !== versionNavigation,
          );
          versionNavigation.remove();
          return;
        }
        const container = this.el.querySelector(
          `.${CLASS_NAMES.VERSION_NAVIGATION_CONTAINER}`,
        );
        if (container) container.appendChild(versionNavigation.el);

        // If not on newest version, show the alert about newest version available
        let latestVersion = null;
        try {
          latestVersion = await dataPackage.getLatestVersionPid({ signal });
        } catch (error) {
          if (isAbortError(error) || !this.isCurrentRender(renderId)) {
            return;
          }
          console.error("Error checking latest dataset version:", error);
        }
        if (!this.isCurrentRender(renderId)) return;
        this.showLatestVersion(latestVersion);
      },

      /**
       * Indicate that the metadata is being loaded
       * @param {string} message - The message to display while loading
       */
      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);
      },

      /**
       * Update the visible loading message, if one is currently rendered.
       * @param {string} message Loading message
       * @returns {void}
       * @since 2.39.0
       */
      updateLoadingText(message) {
        if (!message || typeof message !== "string") return;
        const loadingPara = this.$el
          .find(`.${CLASS_NAMES.loading} > p`)
          .first();
        if (loadingPara.length) loadingPara.text(message);
      },

      /**
       * Update the loading message for a typed DataPackage load phase.
       * @param {object} [progress] DataPackage load progress payload
       * @returns {void}
       * @since 2.39.0
       */
      updateDataPackageLoadProgress(progress = {}) {
        const message = DataPackage.LoadProgressMessages[progress.phase];
        if (message) this.updateLoadingText(message);
      },

      /** Hide the loading message */
      hideLoading() {
        if (this.$loading) this.$loading.remove();
        if (this.$detached) this.$el.html(this.$detached);
      },

      /**
       * Show an app alert owned by MetadataView and keep a handle so it can be
       * removed when this view is replaced.
       * @param {...*} args Arguments forwarded to AppView.showAlert()
       * @returns {Element[]|Element|null} The alert element returned by
       * AppView.showAlert()
       * @since 2.39.0
       */
      showViewAlert(...args) {
        this.removeViewAlert();
        this.activeAlert = MetacatUI.appView.showAlert(...args);
        return this.activeAlert;
      },

      /**
       * Remove the current app alert owned by MetadataView, if any.
       * @returns {void}
       * @since 2.39.0
       */
      removeViewAlert() {
        if (!this.activeAlert) return;
        $(this.activeAlert).remove();
        this.activeAlert = null;
      },

      /**
       * Show an error message to the user
       * @param {string} msg - The error message to display
       * @param {object} [options] - Additional options to pass to the alert
       * template.
       */
      showError(msg, options = {}) {
        // Remove any existing error messages
        this.$el.children(`.${CLASS_NAMES.alertContainer}`).remove();

        this.$el.prepend(
          this.alertTemplate({
            msg,
            classes: CLASS_NAMES.alertError,
            containerClasses: "page",
            includeEmail: true,
            ...options,
          }),
        );
      },

      /**
       * Delegate in-page data previews to the rendered metadata document.
       * @param {Event} event Preview link click
       * @returns {boolean} Whether the member could be previewed
       */
      previewData(event) {
        if (this.metadataView?.previewData) {
          return this.metadataView.previewData(event);
        }
        event.preventDefault();
        return false;
      },

      /**
       * 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} metadataPid - The new metadata PID
       * @param {string} dataPid - Optional. A data PID that's part of the
       *   package metadataPid exists within.
       */
      navigateWithFragment(metadataPid, dataPid) {
        let nextRoute = `view/${encodeURIComponent(metadataPid)}`;
        if (typeof dataPid === "string" && dataPid.length > 0) {
          nextRoute += `#${encodeURIComponent(dataPid)}`;
        }
        MetacatUI.uiRouter.navigate(nextRoute, { trigger: true });
      },

      /**
       * Close any active popovers when the user clicks outside of them
       * @param {Event} e - The click event
       */
      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");
      },

      /**
       * Close and remove the rendered metadata child view.
       * @returns {void}
       * @since 2.39.0
       */
      closeMetadataView() {
        if (this.metadataAbortController) {
          this.metadataAbortController.abort();
          this.metadataAbortController = null;
        }
        this.metadataRenderPid = null;
        this.metadataRenderPromise = null;

        if (!this.metadataView) return;
        const { metadataView } = this;
        this.metadataView = null;
        if (!metadataView.isClosed) {
          metadataView.onClose?.();
          metadataView.remove?.();
        }
        this.subviews = (this.subviews || []).filter(
          (subview) => subview !== metadataView,
        );
      },

      /**
       * Close and remove the package file table child view.
       * @returns {void}
       * @since 2.39.0
       */
      closeFileTableView() {
        this.teardownFileTableScrollIndicators();
        if (!this.fileTableView) return;
        const { fileTableView } = this;
        this.stopListening(fileTableView);
        this.subviews = (this.subviews || []).filter(
          (subview) => subview !== fileTableView,
        );
        if (typeof fileTableView.onClose === "function") {
          fileTableView.onClose();
        } else {
          fileTableView.remove?.();
        }
        this.fileTableView = null;
      },

      /** Actions to perform when the view is closed */
      onClose() {
        this.renderId = null;
        this.abortRender();
        this.stopListening();
        $("meta[name^='citation_']").remove();

        this.removeViewAlert();
        this.closeFileTableView();

        _.each(this.subviews, (subview) => {
          if (subview.onClose) subview.onClose();
        });
        this.subviews = [];
        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(
          `${CLASS_NAMES.container} ${CLASS_NAMES.noStylesheet}`,
        );

        this.$el.empty();
      },

      /**
       * 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() {
        const metadata = this.dataPackage.getPrimaryMetadataMember() || {};
        const id = metadata.pid || metadata.id || this.pid;
        const title = metadata.title || "";
        const authors = metadata.origin || [];
        const publisher = metadata.publisher || metadata.datasource || "";
        const dateValue = metadata.pubDate || metadata.dateUploaded || "";
        const date = dateValue ? new Date(dateValue).getUTCFullYear() : "";
        const isDOI =
          typeof MetacatUI?.appModel?.isDOI === "function" &&
          MetacatUI.appModel.isDOI(id);
        const abstract = metadata.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_']").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,
          }),
        );
      },
    },
  );

  return MetadataView;
});