Source: src/js/models/fileTable/DataPackageFileTableAdapter.js

"use strict";

define([
  "common/ValueUtilities",
  "collections/ObjectFormats",
  "common/UrlUtilities",
], (Values, ObjectFormats, UrlUtilities) => {
  /**
   * @namespace DataPackageFileTableAdapter
   * @description Translates a {@link DataPackage} and its
   * {@link DataPackageMember}s into plain rows ready for rendering by a
   * {@link FileTableViewModel}. This is the only module that depends on both
   * contracts, so generic file table views do not read package members
   *
   * Rows are plain objects suitable for `FileTableViewModel#setRows`.
   * `buildRows()` is pure; `enrichMembers()` fetches the system metadata needed
   * to populate those rows.
   * @classcategory Models/FileTable
   * @since 2.39.0
   */

  const CLASS_NAMES = {
    downloadAction: "btn download btn-rounded action downloadAction",
    downloadIcon: "icon icon-large icon-cloud-download",
    packageDownloadWarningIcon: "icon icon-warning-sign icon-on-left",
    packageDownloadAction: "btn btn-primary downloadAction",
  };

  /** Download availability messages shared with the metadata viewer */
  const MESSAGES = {
    fileDownloadReadDenied:
      "This file is not publicly accessible. Sign in with an account that has access.",
    packageDownloadLabel: "Download All",
    packageDownloadMissing:
      "Some dataset files are missing and will be omitted from this download.",
    packageDownloadReadDenied:
      "Download All is unavailable because you do not have permission to read the entire package.",
    packageDownloadTooLarge:
      "This dataset is too large to download all at once. Please download files individually.",
  };

  /**
   * Per member remote upload state mapped to a row status descriptor. The
   * `title` summarizes what the status icon means and is shown as the cell
   * tooltip.
   */
  const REMOTE_STATE_STATUS = Object.freeze({
    pending: {
      title:
        "Waiting to upload. This file will be saved when you save the dataset.",
      iconClass: "icon icon-circle-blank warning icon-large",
    },
    uploading: {
      title: "Uploading now. This file is being saved to the repository.",
      iconClass: "icon icon-circle-blank warning icon-large",
    },
    uploaded: {
      title: "Saved. This file has been uploaded successfully.",
      iconClass: "icon icon-ok-circle success icon-large",
    },
    failed: {
      title: "Upload failed. Edit the file and save again to retry.",
      iconClass: "icon icon-circle-blank warning icon-large",
    },
    ambiguous: {
      title:
        "Needs verification. We could not confirm whether this file finished uploading.",
      iconClass: "icon icon-circle-blank warning icon-large",
    },
  });

  /**
   * Return members missing file table details or an index result for a
   * ResourceMap member.
   * @param {DataPackage} dataPackage Package to inspect
   * @returns {DataPackageMember[]} Members needing system metadata
   * @private
   */
  function getMembersNeedingSysMeta(dataPackage) {
    const rootPid =
      dataPackage.rootResourceMapPid ||
      dataPackage.getRootResourceMapMember()?.pid;
    return dataPackage.members.getActiveMembers().filter((member) => {
      if (
        !member.pid ||
        member.pid === rootPid ||
        member.sysMeta ||
        member.sysMetaMissing === true ||
        member.sysMetaReadDenied === true
      ) {
        return false;
      }

      const hasSize =
        typeof member.size === "number" ||
        (typeof member.size === "string" && member.size !== "");
      const formatId = member.getFormatId();
      const title = Array.isArray(member.title)
        ? member.title[0]
        : member.title;
      const hasDisplayName = Boolean(member.getFileName() || title);
      return (
        (member.sources.includes("resourceMap") &&
          !member.sources.includes("index")) ||
        member.isPlaceholder() ||
        !hasSize ||
        !formatId ||
        !hasDisplayName
      );
    });
  }

  /**
   * Fetch system metadata needed to populate file table rows.
   * @param {DataPackage} dataPackage Package to enrich
   * @param {object} [options] Options passed to `DataPackage#fetchSysMeta`
   * @returns {Promise<object>} Attempted and fetched PIDs with a change flag
   */
  async function enrichMembers(dataPackage, options = {}) {
    const members = getMembersNeedingSysMeta(dataPackage);
    const attemptedPids = members.map((member) => member.pid);
    if (!attemptedPids.length || options.signal?.aborted) {
      return {
        attemptedPids,
        fetchedPids: [],
        changed: false,
      };
    }

    const errors = await dataPackage.fetchSysMeta(attemptedPids, options);
    let hasChangedMemberState = false;
    errors.forEach(({ pid, error }) => {
      const member = dataPackage.members.get(pid);
      if (!member) return;
      if (error?.status === 401 || error?.status === 403) {
        member.sysMetaReadDenied = true;
        hasChangedMemberState = true;
      } else if (error?.status === 404) {
        member.sysMetaMissing = true;
        hasChangedMemberState = true;
      }
    });
    const fetchedPids = members
      .filter((member) => member.sysMeta)
      .map((member) => member.pid);
    return {
      attemptedPids,
      fetchedPids,
      changed: fetchedPids.length > 0 || hasChangedMemberState,
    };
  }

  /**
   * Read a member's PID.
   * @param {DataPackageMember} member Package member
   * @returns {string|null} Member PID
   */
  function getId(member) {
    return member?.pid || null;
  }

  /**
   * Build a download URL for a member.
   * @param {DataPackageMember} member Package member
   * @param {string} resolveBaseUrl Base resolve URL
   * @returns {string} Download URL, or ""
   */
  function getDownloadUrl(member, resolveBaseUrl) {
    if (member?.sysMetaMissing === true) return "";
    const direct = member?.url || member?.viewServiceEntity?.objectUrl || "";
    if (direct) return direct;
    const pid = getId(member);
    return UrlUtilities.getObjectDownloadUrl(pid, { baseUrl: resolveBaseUrl });
  }

  /**
   * Build a human readable size label.
   * @param {DataPackageMember} member Package member
   * @returns {string} Size label
   */
  function getSizeLabel(member) {
    const size = member?.size;
    const sizeNumber = Number(size);
    if (Number.isFinite(sizeNumber) && size !== "" && size != null) {
      return Values.bytesToSize(sizeNumber);
    }
    return size || "";
  }

  /**
   * Use the pre replacement values while a failed replacement is still
   * unresolved, so the table does not present unuploaded file details as saved.
   * @param {DataPackageMember} member Package member
   * @returns {object|null} Display values for the confirmed remote file
   */
  function getFailedReplacementDisplay(member) {
    if (
      member?.remoteState !== "failed" ||
      !member.remotePid ||
      member.pid === member.remotePid
    ) {
      return null;
    }

    const snapshot = member._replacementDisplay || null;
    const remoteSysMeta = member.remoteSysMeta?.toJSON?.() || {};
    if (snapshot) {
      return {
        pid: snapshot.pid || member.remotePid,
        fileName: snapshot.fileName ?? remoteSysMeta.fileName ?? "",
        title: snapshot.title ?? "",
        size: snapshot.size ?? remoteSysMeta.size,
        formatType: snapshot.formatType ?? member.formatType ?? "",
        formatId:
          snapshot.formatId ?? remoteSysMeta.formatId ?? member.formatId,
        mediaType:
          snapshot.mediaType ?? remoteSysMeta.mediaType ?? member.mediaType,
        atLocations: snapshot.atLocations ?? [],
      };
    }

    return {
      pid: member.remotePid,
      fileName: remoteSysMeta.fileName || "",
      title: member.title || "",
      size: remoteSysMeta.size,
      formatType: member.formatType || "",
      formatId: remoteSysMeta.formatId || member.formatId,
      mediaType: remoteSysMeta.mediaType || member.mediaType,
      atLocations: member.atLocations,
    };
  }

  /**
   * Pick an icon class for a member type.
   * @param {string} type Uppercase format type
   * @param {string} mode Table mode
   * @returns {string} Icon class
   */
  function getIconClass(type, mode) {
    if (mode === "viewer") return "icon-table";
    if (type === "RESOURCE") return "icon-large icon-folder-open";
    return "icon-large icon-file";
  }

  /**
   * Normalize a stored `prov:atLocation` value for display without mutating
   * the underlying graph value. e.g. ./data/../file.csv => file.csv.
   * @param {string} path Raw path like value from the RDF graph
   * @returns {string} Display friendly path string
   */
  function normalizeAtLocationForDisplay(path) {
    const normalizedPath = Values.normalizeText(path) || "";
    if (!normalizedPath) {
      return "/";
    }

    if (
      normalizedPath.startsWith("/") ||
      normalizedPath.startsWith("\\") ||
      normalizedPath.includes("\\") ||
      normalizedPath.includes("://") ||
      /^[A-Za-z]:[\\/]/.test(normalizedPath)
    ) {
      return normalizedPath;
    }

    const resolvedPath = [];

    normalizedPath.split("/").forEach((component, index) => {
      if (
        component === "" ||
        component === "." ||
        (component === "~" && index === 0)
      ) {
        return;
      }

      if (component === "..") {
        // Display normalization behaves like a relative-path collapse, but it
        // never mutates the raw prov:atLocation literal stored in the graph.
        if (resolvedPath.length) {
          resolvedPath.pop();
        }
        return;
      }

      resolvedPath.push(component);
    });

    return resolvedPath.join("/") || "/";
  }

  /**
   * Return location values as an array.
   * @param {string|string[]} locations Raw location value or values
   * @returns {string[]} Location values
   */
  function toLocationArray(locations) {
    if (Array.isArray(locations)) {
      return locations;
    }
    return Values.normalizeText(locations) ? [locations] : [];
  }

  /**
   * Build display friendly paths from raw member atLocations.
   * @param {object} source Member or confirmed remote display source
   * @returns {string[]} Display friendly paths
   */
  function getDisplayAtLocations(source) {
    return toLocationArray(source?.atLocations).map((path) =>
      normalizeAtLocationForDisplay(path),
    );
  }

  /**
   * Derive the folder path a member lives in from its display `atLocation`.
   * Member locations describe object paths, so the final segment is treated as
   * the object path segment and dropped. Explicit folder rows provide the
   * folder path context when users add files to a folder.
   * @param {DataPackageMember} member Package member
   * @param {object|null} [display] Optional confirmed remote display values
   * @returns {string} Slash joined folder path, or "" for top level
   */
  function deriveFolderPath(member, display) {
    const [displayPath] = getDisplayAtLocations(display || member);
    const normalizedPath = Values.normalizeText(displayPath);
    if (!normalizedPath || normalizedPath === "/") return "";

    const segments = normalizedPath
      .split("/")
      .map((segment) => Values.normalizeText(segment))
      .filter((segment) => segment && segment !== ".");
    if (!segments.length) return "";

    segments.pop();
    return segments.join("/");
  }

  /**
   * Build one action descriptor. The accessible label defaults to the title.
   * @param {string} id Action id
   * @param {string} label Button label
   * @param {string} title Tooltip/accessible title
   * @param {string} [iconClass] Icon class
   * @param {object} [options] Extra action state
   * @returns {object} Action descriptor
   */
  function action(id, label, title, iconClass = "", options = {}) {
    return { id, label, title, ariaLabel: title, iconClass, ...options };
  }

  /**
   * Build the editor share action for one row.
   * @param {string} label Target label
   * @param {boolean} readDenied Whether system metadata access was denied
   * @returns {object} Share action descriptor
   */
  function shareAction(label, readDenied = false) {
    return action(
      "share",
      "Share",
      readDenied
        ? `You do not have permission to read ${label}, so its sharing settings cannot be changed.`
        : `Click to change who can access ${label}`,
      "icon icon-group icon-on-left",
      {
        className: "btn access-policy-control",
        isDisabled: readDenied,
      },
    );
  }

  /**
   * Describe a member's format type for the type column tooltip.
   * @param {DataPackageMember} member Package member
   * @param {string} type Uppercase format type
   * @param {string} typeLabel Friendly type label shown in the cell
   * @returns {string} Human readable description of the type
   */
  function getTypeTooltip(member, type, typeLabel) {
    const detail =
      member?.getFormatProperties?.().mediaType ||
      member?.getFormatId?.() ||
      member?.formatId ||
      "";
    let lead;
    if (type === "METADATA") lead = "Metadata that describes this dataset";
    else if (type === "RESOURCE") lead = "A data package (resource map)";
    else lead = "A data file";
    const parts = [lead];
    if (detail && detail !== typeLabel) parts.push(`format: ${detail}`);
    return parts.join(" - ");
  }

  /**
   * Build the action descriptors for one member row.
   * @param {object} context Row context
   * @param {string} context.mode "viewer" or "editor"
   * @param {string} context.type Member format type
   * @param {string} context.label Display label
   * @param {string} context.downloadUrl Download URL
   * @param {boolean} context.isResourceMapMember Whether this row is a resource
   * map
   * @param {boolean} context.isNestedPackage Whether this row links to a
   * nested dataset
   * @param {boolean} context.isUnavailable Whether object metadata is missing
   * @param {boolean} context.sysMetaReadDenied Whether system metadata access
   * was denied
   * @param {boolean} context.downloadReadDenied Whether a download was denied
   * @returns {object[]} Action descriptors
   */
  function buildActions({
    mode,
    type,
    label,
    downloadUrl,
    isResourceMapMember,
    isNestedPackage,
    isUnavailable,
    sysMetaReadDenied,
    downloadReadDenied,
  }) {
    const actions = [];

    if (mode === "editor") {
      // The editor never offers a download; files are edited, not downloaded.
      if (isNestedPackage) {
        actions.push(
          action(
            "remove",
            "",
            `Remove ${label} from this dataset`,
            "icon icon-remove",
          ),
        );
        return actions;
      }
      if (type === "METADATA") {
        actions.push(
          action(
            "add-files",
            "Add Files",
            `Add files documented by ${label}`,
            "icon icon-large icon-plus icon-on-left",
            { className: "addFiles btn btn-primary" },
          ),
        );
      }
      if (type === "DATA") {
        actions.push(
          action(
            "describe",
            "Describe",
            `Add details about ${label}, including descriptions of its attributes (columns)`,
            "",
            {
              className: "btn edit",
              menuItems: [
                action(
                  "replace",
                  "Replace",
                  sysMetaReadDenied
                    ? `You do not have permission to read ${label}, so it cannot be replaced.`
                    : `Replace ${label} with a different file`,
                  "",
                  { isDisabled: sysMetaReadDenied },
                ),
                action("remove", "Remove", `Remove ${label} from this dataset`),
              ],
            },
          ),
        );
      } else if (!isResourceMapMember && type !== "METADATA") {
        // Data and other non-metadata files are removable; the primary
        // metadata (EML) and the resource map are not.
        actions.push(
          action(
            "remove",
            "Remove",
            `Remove ${label} from this dataset`,
            "icon icon-remove",
          ),
        );
      }
      return actions;
    }

    // Viewer mode: metadata is download-only; other files also get a details
    // action.
    if (isUnavailable) {
      return actions;
    }
    if (isNestedPackage) {
      actions.push(
        action(
          "open-dataset",
          "",
          `Open ${label}`,
          "icon icon-large icon-external-link",
          { className: "btn btn-rounded action" },
        ),
      );
    } else if (!isResourceMapMember && type !== "METADATA") {
      actions.push(
        action(
          "preview",
          "",
          `View details, attributes, and a preview of ${label}`,
          "icon icon-large icon-info",
          { className: "btn btn-rounded action preview" },
        ),
      );
    }
    if (downloadUrl) {
      let title = `Download ${label} to your computer`;
      if (downloadReadDenied) {
        title = isNestedPackage
          ? MESSAGES.packageDownloadReadDenied
          : MESSAGES.fileDownloadReadDenied;
      }
      actions.push(
        action("download", "", title, CLASS_NAMES.downloadIcon, {
          className: CLASS_NAMES.downloadAction,
          isDisabled: downloadReadDenied,
        }),
      );
    }
    return actions;
  }

  /**
   * Resolve a friendly type label for a member, using short built in labels
   * before an injected object format resolver and the member's own media/format
   * fields.
   * @param {DataPackageMember} member Package member
   * @param {string} type Uppercase format type
   * @param {Function|null} formatName Resolver `(formatId, type) => string`
   * @returns {string} Friendly type label
   */
  function getTypeLabel(member, type, formatName) {
    const formatId = member?.getFormatId?.() || member?.formatId || "";
    const mediaType =
      member?.getFormatProperties?.().mediaType || member?.mediaType || "";
    const shortFormatId = ObjectFormats.getFriendlyFormat(formatId);
    if (shortFormatId && shortFormatId !== formatId) return shortFormatId;
    const shortMediaType = ObjectFormats.getFriendlyFormat(mediaType);
    if (shortMediaType && shortMediaType !== mediaType) return shortMediaType;
    const friendly = formatName ? formatName(formatId, type) : "";
    return friendly || mediaType || member?.formatType || formatId || "";
  }

  /**
   * Build a render ready row for one member.
   * @param {DataPackageMember} member Package member
   * @param {object} options Build options
   * @param {string} options.mode "viewer" or "editor"
   * @param {string} options.resolveBaseUrl Base resolve URL for downloads
   * @param {Function} [options.formatName] Friendly format resolver
   * @param {boolean} [options.showMetrics] Whether to populate metric fields
   * @param {Function} [options.getRowMetric] Metric resolver
   * `(member, type) => {label, title, iconClass}|null`
   * @param {Function} [options.getMemberStatus] Status resolver
   * `(member, type) => {label, title, iconClass, progress}|null`
   * @param {string} [options.packageId] Root resource map PID
   * @param {string} [options.packageServiceUrl] Package download service URL
   * @param {boolean} [options.showShare] Whether sharing controls are shown
   * @param {object|null} [options.display] Optional confirmed remote display
   * values for unresolved failed replacements
   * @param {boolean} [options.isEagerUploading] Whether member editing is locked
   * @param {boolean} [options.downloadReadDenied] Whether a download was denied
   * @returns {object|null} Row object, or null when the member has no PID
   */
  function buildRow(
    member,
    {
      mode = "viewer",
      resolveBaseUrl = "",
      formatName = null,
      showMetrics = false,
      getRowMetric = null,
      getMemberStatus = null,
      packageId = "",
      packageServiceUrl = "",
      showShare = true,
      display = null,
      isEagerUploading = false,
      downloadReadDenied = false,
    },
  ) {
    const id = getId(member);
    if (!id) return null;

    const rowDisplay = display || getFailedReplacementDisplay(member);
    const type = member.getFormatType();
    const fileName = rowDisplay
      ? rowDisplay.fileName || ""
      : member?.getFileName();
    const displayPid = rowDisplay?.pid || id;
    const rawTitle = rowDisplay ? rowDisplay.title : member?.title;
    const displayTitle = Array.isArray(rawTitle)
      ? rawTitle[0] || ""
      : rawTitle || "";
    const typeMember = rowDisplay
      ? {
          formatType: rowDisplay.formatType || member.formatType,
          formatId: rowDisplay.formatId || "",
          mediaType: rowDisplay.mediaType || "",
          getFormatId: () => rowDisplay.formatId || null,
          getFormatProperties: () => ({
            formatType: rowDisplay.formatType || type || "",
            formatId: rowDisplay.formatId || "",
            mediaType: rowDisplay.mediaType || "",
            filename: fileName || "",
          }),
        }
      : member;
    const isResourceMapMember = member.isResourceMap();
    const isUnavailable = member?.sysMetaMissing === true;
    const isNestedPackage =
      Boolean(packageId) && isResourceMapMember && id !== packageId;
    let label = isNestedPackage
      ? displayTitle || fileName || displayPid
      : fileName || displayTitle || displayPid;
    if (mode === "viewer" && type === "METADATA" && fileName) {
      label = `Metadata: ${fileName}`;
    }
    let downloadUrl = getDownloadUrl(member, resolveBaseUrl);
    if (isNestedPackage) {
      downloadUrl = packageServiceUrl
        ? packageServiceUrl + encodeURIComponent(id)
        : "";
    }
    const customStatus =
      mode === "editor" && getMemberStatus
        ? getMemberStatus(member, type)
        : null;
    const statusConfig = REMOTE_STATE_STATUS[member?.remoteState];
    const status =
      customStatus ||
      (mode === "editor" && statusConfig
        ? { ...statusConfig, className: `status-${member.remoteState}` }
        : null);
    if (
      status &&
      member?.remoteState === "failed" &&
      member?.lastUploadError?.message
    ) {
      status.title = `Upload failed: ${member.lastUploadError.message}`;
    }
    const atLocations = toLocationArray(
      rowDisplay?.atLocations || member?.atLocations || [],
    );
    const displayLocations = getDisplayAtLocations(rowDisplay || member);
    const atLocation = atLocations[0] || "";
    const displayAtLocation = displayLocations[0] || atLocation;
    const metric =
      showMetrics && getRowMetric ? getRowMetric(member, type) : null;
    let kind = (type || "data").toLowerCase();
    if (isResourceMapMember) kind = "resource-map";
    if (isNestedPackage) kind = "dataset";
    const typeLabel = isNestedPackage
      ? "Nested package"
      : getTypeLabel(typeMember, type, formatName);
    let typeTooltip = "";
    if (isNestedPackage) {
      typeTooltip = "A different dataset nested under this one";
    } else if (typeLabel) {
      typeTooltip = getTypeTooltip(typeMember, type, typeLabel);
    }
    const shareEnabled =
      mode === "editor" && showShare !== false && !isEagerUploading;

    return {
      id,
      pid: displayPid,
      parentId: "",
      label,
      title: displayTitle,
      name: fileName || "",
      // The tooltip carries the full display label (file name or package
      // title) so the complete text is recoverable when the cell truncates it.
      // The PID stays available in `id`/`pid`.
      titleTooltip: label,
      kind,
      className: isNestedPackage ? "nested-dataset" : "",
      iconClass: isNestedPackage ? "" : getIconClass(type, mode),
      sizeLabel: isNestedPackage ? "" : getSizeLabel(rowDisplay || member),
      typeLabel,
      typeTooltip,
      atLocation,
      displayAtLocation,
      downloadUrl,
      status,
      showMetrics: Boolean(showMetrics),
      showShare: shareEnabled,
      shareAction:
        shareEnabled && !isResourceMapMember
          ? shareAction(label, member.sysMetaReadDenied === true)
          : null,
      metricLabel: metric?.label || "",
      metricTitle: metric?.title || "",
      metricIconClass: metric?.iconClass || "",
      isContainer: false,
      isRenamable:
        mode === "editor" &&
        type === "DATA" &&
        !isResourceMapMember &&
        !isEagerUploading,
      showStatus: mode === "editor",
      actions: isEagerUploading
        ? []
        : buildActions({
            mode,
            type,
            label,
            downloadUrl,
            isResourceMapMember,
            isNestedPackage,
            isUnavailable,
            sysMetaReadDenied: member.sysMetaReadDenied === true,
            downloadReadDenied,
          }),
    };
  }

  /**
   * Reserve a unique id for a synthetic row.
   * @param {string} preferredId Descriptive id to use when available
   * @param {Set<string>} occupiedIds Reserved row ids
   * @returns {string} Reserved synthetic row id
   */
  function allocateSyntheticId(preferredId, occupiedIds) {
    let candidate = preferredId;
    let suffix = 1;

    while (occupiedIds.has(candidate)) {
      candidate = `${preferredId}:${suffix}`;
      suffix += 1;
    }

    occupiedIds.add(candidate);
    return candidate;
  }

  /**
   * Build folder rows for a set of folder paths, including ancestors, and
   * return them keyed by path.
   * @param {Set<string>} folderPaths Distinct non empty folder paths
   * @param {object} options Build options
   * @param {string} options.mode "viewer" or "editor"
   * @param {boolean} [options.showMetrics] Whether metrics are shown
   * @param {Set<string>} options.occupiedIds Reserved row ids
   * @returns {Map<string, object>} Folder rows keyed by folder path
   */
  function buildFolderRows(
    folderPaths,
    { mode, showMetrics = false, occupiedIds },
  ) {
    const folders = new Map();

    const ensureFolder = (path) => {
      if (!path || folders.has(path)) return;
      const segments = path.split("/");
      const name = segments[segments.length - 1];
      const parentPath = segments.slice(0, -1).join("/");
      ensureFolder(parentPath);
      folders.set(path, {
        id: allocateSyntheticId(`folder:${path}`, occupiedIds),
        parentId: parentPath ? folders.get(parentPath).id : "",
        label: name,
        name,
        atLocation: path,
        displayAtLocation: path,
        kind: "folder",
        iconClass: "icon icon-folder-open",
        isContainer: true,
        isExpandable: true,
        // All folders (top-level and nested) load collapsed; only the dataset
        // root row is expanded by default.
        isExpanded: false,
        acceptsFiles: mode === "editor",
        level: segments.length - 1,
        showStatus: mode === "editor",
        showMetrics: Boolean(showMetrics),
        actions:
          mode === "editor"
            ? [
                action(
                  "add-files",
                  "Add files",
                  `Add files to ${path}`,
                  "icon icon-large icon-plus icon-on-left",
                  { className: "addFiles btn btn-primary" },
                ),
              ]
            : [],
      });
    };

    folderPaths.forEach(ensureFolder);
    return folders;
  }

  /**
   * Rank a row within its parent's children: metadata first, nested packages
   * next, folders next, plain files last.
   * @param {object} row Row
   * @returns {number} Sort rank
   */
  function childRank(row) {
    if (row.kind === "dataset") return row.parentId ? 1 : -1;
    if (row.kind === "metadata") return 0;
    if (row.kind === "folder" || row.isContainer) return 2;
    return 3;
  }

  /**
   * Order rows depth first so each folder is immediately followed by its
   * descendants, with each parent's children grouped metadata -> folders ->
   * files.
   * @param {object[]} rows All rows (folders and files)
   * @returns {object[]} Depth first ordered rows
   */
  function orderRowsDepthFirst(rows) {
    const childrenByParent = new Map();
    rows.forEach((row) => {
      const key = row.parentId || "";
      if (!childrenByParent.has(key)) childrenByParent.set(key, []);
      childrenByParent.get(key).push(row);
    });
    childrenByParent.forEach((children) => {
      children.sort((a, b) => childRank(a) - childRank(b));
    });

    const ordered = [];
    const visit = (parentId) => {
      (childrenByParent.get(parentId) || []).forEach((row) => {
        ordered.push(row);
        visit(row.id);
      });
    };
    visit("");
    return ordered;
  }

  /**
   * Build the top level dataset row that stands in for the package itself. All
   * other rows nest beneath it, mirroring the legacy table where the root row
   * was the dataset name and the resource map had no row of its own.
   * @param {object} options Root options
   * @param {string|string[]} options.packageTitle Dataset title
   * @param {string} options.packageId Package/root resource map PID
   * @param {string} options.packageDownloadUrl Whole package download URL
   * @param {string} options.packageDownloadUnavailableReason Explanation shown
   * when whole package download is unavailable
   * @param {boolean} options.hasMissingMembers Whether any package files are missing
   * @param {string} options.mode "viewer" or "editor"
   * @param {boolean} options.showMetrics Whether metrics are shown
   * @param {boolean} options.showShare Whether sharing controls are shown
   * @param {string} [options.preferredDatasetRootId] Preferred synthetic root
   * id when a caller needs a stable identity across package PID changes
   * @param {Set<string>} options.occupiedIds Reserved row ids
   * @returns {object} Dataset root row
   */
  function buildDatasetRootRow({
    packageTitle,
    packageId,
    packageDownloadUrl,
    packageDownloadUnavailableReason,
    hasMissingMembers,
    mode,
    showMetrics,
    showShare = true,
    preferredDatasetRootId = "",
    occupiedIds,
  }) {
    const title = Array.isArray(packageTitle)
      ? packageTitle[0] || ""
      : packageTitle;
    const label = title || packageId || "Dataset";
    const warning =
      hasMissingMembers && !packageDownloadUnavailableReason
        ? MESSAGES.packageDownloadMissing
        : "";
    const shareEnabled = mode === "editor" && showShare !== false;
    const actions = [];
    if (mode === "editor") {
      actions.push(
        action(
          "add-files",
          "Add Files",
          `Add files to ${label}`,
          "icon icon-large icon-plus icon-on-left",
          {
            className: "addFiles btn btn-primary",
          },
        ),
      );
    } else if (packageDownloadUrl || packageDownloadUnavailableReason) {
      actions.push(
        action(
          "download",
          MESSAGES.packageDownloadLabel,
          packageDownloadUnavailableReason ||
            warning ||
            `Download all files in ${label}`,
          warning ? CLASS_NAMES.packageDownloadWarningIcon : "",
          {
            className: CLASS_NAMES.packageDownloadAction,
            ...(warning && {
              ariaLabel: `${MESSAGES.packageDownloadLabel}. ${warning}`,
            }),
            isDisabled: Boolean(packageDownloadUnavailableReason),
          },
        ),
      );
    }

    return {
      id: allocateSyntheticId(
        preferredDatasetRootId ||
          (packageId ? `dataset:${packageId}` : "dataset:root"),
        occupiedIds,
      ),
      pid: packageId || "",
      parentId: "",
      label,
      name: label,
      title: title || "",
      titleTooltip: title || label,
      kind: "dataset",
      iconClass: "",
      className: "root-dataset",
      sizeLabel: "",
      typeTooltip: "The primary dataset shown on this page",
      downloadUrl: packageDownloadUnavailableReason
        ? ""
        : packageDownloadUrl || "",
      level: 0,
      isContainer: true,
      isExpandable: true,
      isExpanded: true,
      acceptsFiles: mode === "editor",
      showMetrics: Boolean(showMetrics),
      showShare: shareEnabled,
      shareAction: shareEnabled ? shareAction(label) : null,
      showStatus: mode === "editor",
      isRenamable: mode === "editor",
      actions,
    };
  }

  /**
   * Build all file table rows for a package, including a folder hierarchy
   * derived from member `atLocation` paths.
   *
   * When `packageTitle` or `packageId` is supplied, the table is rendered as a
   * dataset: a single root row (the dataset) is added, every other row nests
   * beneath it, the resource map is hidden (the root stands in for it), and the
   * primary metadata is grouped first.
   * @param {DataPackage} dataPackage Package to render
   * @param {object} [options] Build options
   * @param {string} [options.mode] "viewer" (default) or "editor"
   * @param {string} [options.resolveBaseUrl] Base resolve URL for downloads
   * @param {DataPackageMember[]} [options.members] Members to render. Defaults
   * to the package's active members
   * @param {number} [options.maxMembers] Maximum non-root members to render
   * @param {boolean} [options.showMetrics] Whether to populate metric fields
   * @param {Function} [options.formatName] Friendly format resolver
   * `(formatId, type) => string`
   * @param {Function} [options.getRowMetric] Metric resolver
   * `(member, type) => {label, title, iconClass}|null`
   * @param {Function} [options.getMemberStatus] Status resolver
   * `(member, type) => {label, title, iconClass, progress}|null`
   * @param {boolean} [options.showShare] Whether sharing controls are shown
   * @param {string|string[]} [options.packageTitle] Dataset title for the root
   * row
   * @param {string} [options.packageId] Package (root resource map) PID
   * @param {string} [options.preferredDatasetRootId] Preferred synthetic root
   * id when a caller needs a stable identity across package PID changes
   * @param {string} [options.packageServiceUrl] Package download service URL
   * @param {string} [options.packageDownloadUrl] Whole package download URL
   * @param {string} [options.packageDownloadUnavailableReason] Explanation
   * shown when whole package download is unavailable
   * @param {Set<string>} [options.downloadReadDeniedPids] Member PIDs whose
   * download requests were denied
   * @returns {object[]} Render ready rows for `FileTableViewModel#setRows`
   */
  function buildRows(dataPackage, options = {}) {
    const {
      mode = "viewer",
      resolveBaseUrl = "",
      showMetrics = false,
      formatName = null,
      getRowMetric = null,
      getMemberStatus = null,
      showShare = true,
      maxMembers,
      packageTitle = "",
      packageId = "",
      preferredDatasetRootId = "",
      packageServiceUrl = "",
      packageDownloadUrl = "",
      packageDownloadUnavailableReason = "",
      downloadReadDeniedPids,
    } = options;
    const rowOptions = {
      mode,
      resolveBaseUrl,
      showMetrics,
      formatName,
      getRowMetric,
      getMemberStatus,
      packageId,
      packageServiceUrl,
      showShare,
    };
    const datasetMode = Boolean(packageTitle || packageId);
    const allMembers =
      options.members || dataPackage?.members?.getActiveMembers?.() || [];
    const occupiedIds = new Set(allMembers.map(getId).filter(Boolean));
    // In dataset mode the root row represents this package's resource map.
    // Other resource maps are nested datasets and remain visible.
    const rootResourceMapPid =
      packageId ||
      dataPackage?.rootResourceMapPid ||
      dataPackage?.getRootResourceMapMember?.()?.pid;
    const rootResourceMap = rootResourceMapPid
      ? allMembers.find((member) => getId(member) === rootResourceMapPid)
      : allMembers.find((member) => member?.isResourceMap());
    const packageMembers = datasetMode
      ? allMembers.filter((member) => member !== rootResourceMap)
      : allMembers;
    const primaryMetadataPid = dataPackage?.getPrimaryMetadataMember?.()?.pid;
    const primaryMetadata = packageMembers.find(
      (member) => getId(member) === primaryMetadataPid,
    );
    const orderedMembers = primaryMetadata
      ? [
          primaryMetadata,
          ...packageMembers.filter((member) => member !== primaryMetadata),
        ]
      : packageMembers;
    const members =
      maxMembers == null ? orderedMembers : orderedMembers.slice(0, maxMembers);

    const fileRows = [];
    const folderPaths = new Set();
    const folderPathByRow = new Map();

    members.forEach((member) => {
      const memberId = getId(member);
      const display = getFailedReplacementDisplay(member);
      const row = buildRow(member, {
        ...rowOptions,
        display,
        downloadReadDenied: downloadReadDeniedPids?.has(memberId) === true,
        isEagerUploading: dataPackage?.eagerUploads?.has(memberId) === true,
      });
      if (!row) return;
      const folderPath = deriveFolderPath(member, display);
      if (folderPath) {
        row.level = folderPath.split("/").length;
        folderPaths.add(folderPath);
        folderPathByRow.set(row, folderPath);
      }
      fileRows.push(row);
    });

    const folders = folderPaths.size
      ? buildFolderRows(folderPaths, { mode, showMetrics, occupiedIds })
      : new Map();
    folderPathByRow.forEach((folderPath, row) => {
      const target = row;
      target.parentId = folders.get(folderPath).id;
    });
    let rows = [...folders.values(), ...fileRows];

    if (datasetMode) {
      const rootRow = buildDatasetRootRow({
        packageTitle,
        packageId,
        packageDownloadUrl,
        packageDownloadUnavailableReason,
        hasMissingMembers: allMembers.some(
          (member) => member.sysMetaMissing === true,
        ),
        mode,
        showMetrics,
        showShare,
        preferredDatasetRootId,
        occupiedIds,
      });
      rows.forEach((row) => {
        const target = row;
        if (!target.parentId) target.parentId = rootRow.id;
        target.level = (Number(target.level) || 0) + 1;
      });
      rows = [rootRow, ...rows];
    }

    // A flat table with no hierarchy keeps its input order.
    if (!folderPaths.size && !datasetMode) return fileRows;
    return orderRowsDepthFirst(rows);
  }

  return {
    MESSAGES,
    enrichMembers,
    buildRows,
  };
});