30a4424ce1b185ef0e6e95969e0b688d686dfb8d
chmalee
  Wed May 6 11:52:40 2026 -0700
HubSpace assembly hub fixes: fixes 1,5,6,8 and prettify error messages, refs #37411

diff --git src/hg/js/hgMyData.js src/hg/js/hgMyData.js
index d9298b42ef0..5c23bbf4183 100644
--- src/hg/js/hgMyData.js
+++ src/hg/js/hgMyData.js
@@ -180,30 +180,35 @@
                     }
                     return h('div', {
                             class: "uppy-Dashboard-FileCard-label",
                             style: "display: inline-block; width: 78%"
                             },
                         label,
                         h('input', {
                             id: `${file.meta.name}AsmHubInput`,
                             type: 'text',
                             class: "uppy-u-reset uppy-c-textInput uppy-Dashboard-FileCard-input",
                             style: "margin-left: 5px",
                             value: file.meta.genome || "",
                             disabled: !editable2bit,
                             onChange: e => {
                                 let v = hubCreate.sanitizeGenomeName(e.target.value);
+                                if (!v) {
+                                    // Empty input: revert rather than blank out meta.
+                                    e.target.value = file.meta.genome || "";
+                                    return;
+                                }
                                 onChange(v);
                                 file.meta.genome = v;
                                 file.meta.genomeLabel = v;
                             }
                         })
                     );
                 }
                 // keep these as a variable so we can init the autocompleteCat
                 // code only after the elements have actually been rendered
                 // there are multiple rendering passes and only eventually
                 // do the elements actually make it into the DOM
                 let ret = h('div', {
                         class: "uppy-Dashboard-FileCard-label",
                         style: "display: inline-block; width: 78%"
                         },
@@ -283,36 +288,53 @@
     doneButtonHandler: function() {
         uppy.clear();
     },
 };
 
 // make our Uppy instance:
 const uppy = new Uppy.Uppy({
     debug: true,
     allowMultipleUploadBatches: false,
     onBeforeUpload: (files) => {
         // set all the fileTypes and genomes from their selects
         let doUpload = true;
         let thisQuota = 0;
         let filesToOverwrite = []; // collect files that will overwrite existing ones
 
+        // Only one 2bit per hub is supported. propagateAssemblyHubMeta picks
+        // the first 2bit found and overwrites every sibling's genome with it,
+        // which silently orphans the second 2bit (lands on disk and in the
+        // hubSpace table but is not referenced from hub.txt).
+        let twoBitsInBatch = Object.values(files).filter(looksLikeTwoBit);
+        if (twoBitsInBatch.length > 1) {
+            let names = twoBitsInBatch.map(f => f.name).join(", ");
+            uppy.info(`Error: only one 2bit file per hub is supported. ` +
+                      `Found: ${names}. Upload one 2bit at a time, or split ` +
+                      `them into separate hubs.`, "error", 6000);
+            return false;
+        }
+
         // If a 2bit is in the batch, propagate its genome/hubType to siblings.
-        // Fall back to sanitized filename because setFileMeta is async from
-        // our point of view - captured meta may not be populated yet.
-        let batchTwoBit = Object.values(files).find(looksLikeTwoBit);
+        let batchTwoBit = twoBitsInBatch[0];
         if (batchTwoBit) {
-            let asmGenome = batchTwoBit.meta.genome || hubCreate.sanitizeGenomeName(batchTwoBit.name);
+            let asmGenome = batchTwoBit.meta.genome;
+            if (!asmGenome) {
+                uppy.info(`Error: Genome name is required for ` +
+                          `${batchTwoBit.name}. Open the file card and enter ` +
+                          `a name for your assembly.`, "error", 5000);
+                return false;
+            }
             for (let f of Object.values(files)) {
                 f.meta.genome = asmGenome;
                 f.meta.genomeLabel = asmGenome;
                 f.meta.hubType = "assemblyHub";
                 // fileType may also be stale; recompute from filename if missing
                 if (!f.meta.fileType) {
                     f.meta.fileType = hubCreate.detectFileType(f.name);
                 }
             }
         }
 
         // Tag every file so pre-finish knows a user hub.txt is coming in
         // the same batch and can skip synthesizing its own.
         let hasHubTxt = Object.values(files).some(looksLikeHubTxt);
         for (let f of Object.values(files)) {
@@ -384,30 +406,46 @@
             if (!confirm(`The following file(s) already exist and will be overwritten:\n  ${fileNames}\n\nContinue?`)) {
                 doUpload = false;
             } else {
                 // Set metadata flag to allow overwrite on backend for each file
                 filesToOverwrite.forEach(f => f.meta.allowOverwrite = "true");
             }
         }
         if (thisQuota + hubCreate.uiState.userQuota > hubCreate.uiState.maxQuota) {
             uppy.info(`Error: this file batch exceeds your quota. Please delete some files to make space or email genome-www@soe.ucsc.edu if you feel you need more space.`);
             doUpload = false;
         }
         return doUpload ? files : false;
     },
 });
 
+function extractHookErrorMessage(error, response) {
+    // Our hooks exit 0 + RejectUpload=true, so the response body is the raw
+    // errAbort message. tus-js-client still wraps error.message with
+    // "tus: unexpected response while ..., response text: <ours>, request
+    // id: n/a" when the status code is 4xx/5xx.
+    if (response && response.body) return String(response.body).trim();
+    let body = null;
+    try { body = error && error.originalResponse && error.originalResponse.getBody(); }
+    catch (e) { /* ignore */ }
+    if (body) return String(body).trim();
+    let msg = (error && error.message) || "Upload failed";
+    // Scrape the tus wrapping off if present.
+    let m = msg.match(/response text:\s*([\s\S]*?)(?:,\s*request id:|$)/);
+    return m ? m[1].trim() : msg;
+}
+
 function looksLikeTwoBit(f) {
     return (f.name || "").toLowerCase().endsWith(".2bit");
 }
 
 function looksLikeHubTxt(f) {
     // Accept exact "hub.txt" or any "*.hub.txt" (e.g. "araTha1.hub.txt").
     let n = (f.name || "").toLowerCase();
     return n === "hub.txt" || n.endsWith(".hub.txt");
 }
 
 function propagateAssemblyHubMeta(uppyInstance) {
     // When a batch contains a 2bit (and/or an assembly-hub hub.txt), mirror the
     // custom genome name onto every file sharing that parentDir and mark every
     // file hubType=assemblyHub. hub.txt wins over the 2bit's default.
     //
@@ -668,30 +706,52 @@
             if (batchDbSelect && batchDbGenomeSearchBar && batchDbGenomeSearchButton) {
                 let justInitted = initAutocompleteForInput(batchDbGenomeSearchBar.id, batchDbSelect);
                 if (justInitted) {
                     batchDbGenomeSearchButton.addEventListener("click", (e) => {
                         let inp = document.getElementById(batchDbGenomeSearchBar.id).value;
                         let selector = "[id='"+batchDbGenomeSearchBar.id+"']";
                         $(selector).autocompleteCat("search", inp);
                     });
                 }
             }
         }
     }
 
     install() {
         this.uppy.on("file-added", (file) => {
+            // Only one 2bit per hub is supported. If this batch already has
+            // a 2bit, reject the new one before any meta-setting runs (which
+            // would propagate the wrong genome to it). onBeforeUpload also
+            // enforces this; the duplicate check here keeps the per-file card
+            // UI from drifting.
+            if (looksLikeTwoBit(file)) {
+                let existingTwoBits = this.uppy.getFiles().filter(
+                    f => f.id !== file.id && looksLikeTwoBit(f));
+                if (existingTwoBits.length > 0) {
+                    this.uppy.removeFile(file.id);
+                    // setState({error}) puts the message in the StatusBar at
+                    // the bottom of the dashboard where it stays until the
+                    // user clicks Upload (which clears state.error) or closes
+                    // the dashboard. Persistent, unlike uppy.info().
+                    this.uppy.setState({
+                        error: `Only one 2bit file per hub is supported. ` +
+                               `Already have "${existingTwoBits[0].name}"; ignoring ` +
+                               `"${file.name}". Split them into separate hubs.`
+                    });
+                    return;
+                }
+            }
             // add default meta data for genome and fileType
             let ftype = hubCreate.detectFileType(file.name);
             let defaultMeta = {
                 "genome": hubCreate.defaultDb(),
                 "fileType": ftype,
                 "parentDir": hubCreate.getDefaultHubName(),
                 "hubType": "trackHub",
             };
             if (ftype === "2bit") {
                 // This file defines an assembly hub. Default the genome to the
                 // sanitized filename stem; the user can edit it in the file card.
                 defaultMeta.genome = hubCreate.sanitizeGenomeName(file.name);
                 defaultMeta.genomeLabel = defaultMeta.genome;
                 defaultMeta.hubType = "assemblyHub";
             }
@@ -1094,30 +1154,52 @@
                 }
             });
             window.location.assign(url);
             return false;
         }
     }
 
     function deleteFileSuccess(jqXhr, textStatus) {
         deleteFileFromTable(jqXhr.deletedList);
         updateSelectedFileDiv(null);
     }
 
     function deleteFileList(ev) {
         // same as deleteFile() but acts on the selectedData variable
         let data = selectedData;
+        // Block deletion of an assembly hub's defining 2bit unless the whole hub
+        // is also in this batch. Removing the 2bit alone leaves hub.txt with a
+        // twoBitPath pointing at a missing file and the surviving rows still
+        // flagged hubType=assemblyHub. The user must delete the entire hub
+        // instead, or replace the 2bit by uploading a new one with the same name.
+        let selectedValues = Object.values(data);
+        let selectedHubDirs = new Set(
+            selectedValues.filter(x => x.fileType === "dir").map(x => x.fullPath));
+        let blockedTwoBits = [];
+        for (let d of selectedValues) {
+            if (d.fileType !== "2bit") continue;
+            let hub = uiState.filesHash[d.parentDir];
+            if (!hub || hub.hubType !== "assemblyHub") continue;
+            if (!selectedHubDirs.has(d.parentDir)) blockedTwoBits.push(d);
+        }
+        if (blockedTwoBits.length > 0) {
+            let names = blockedTwoBits.map(d => d.fullPath).join("\n  ");
+            alert(`Cannot delete the following 2bit file(s) because they define ` +
+                  `an assembly hub:\n  ${names}\n\nDelete the whole hub instead, ` +
+                  `or replace the 2bit by uploading a new one with the same name.`);
+            return;
+        }
         // Only warn about hub.txt deletion if the user directly selected the hub.txt file,
         // not if it's being deleted as part of selecting a whole hub/directory
         let hasDirectlySelectedHubTxt = Object.values(directlySelected).some(d => d.fileType === "hub.txt");
         if (hasDirectlySelectedHubTxt) {
             if (!confirm("Warning: Deleting a hub.txt file will remove your hub and its shareable URL. Are you sure?")) {
                 return;
             }
         }
         let cartData = {deleteFile: {fileList: []}};
         cart.setCgiAndUrl(fileListEndpoint);
         _.forEach(data, (d) => {
             cartData.deleteFile.fileList.push({
                 fileName: d.fileName,
                 fileType: d.fileType,
                 parentDir: d.parentDir,
@@ -1836,30 +1918,43 @@
         // first add the top level directories/files
         let table = showExistingFiles(uiState.fileList);
 
         uppy.use(Uppy.Dashboard, uppyOptions);
 
         // define this in init so globals are available at runtime
         let tusOptions = {
             endpoint: getTusdEndpoint(),
             withCredentials: true,
             retryDelays: null,
             removeFingerprintOnSuccess: true, // clean up localStorage after successful upload
         };
 
         uppy.use(Uppy.Tus, tusOptions);
         uppy.use(BatchChangePlugin, {target: Uppy.Dashboard});
+        uppy.on('upload-error', (file, error, response) => {
+            // Replace tus's verbose default ("tus: unexpected response while
+            // uploading chunk, originated from request (method: PATCH, ...)")
+            // with the message our hook actually sent. Overwrite per-file
+            // state, global state.error (read by the StatusBar), and the
+            // info[] array (transient banner) - Uppy core populates all three
+            // with the wrapped message before this handler runs.
+            let cleanMsg = extractHookErrorMessage(error, response);
+            if (file) {
+                uppy.setFileState(file.id, {error: cleanMsg});
+            }
+            uppy.setState({error: cleanMsg, info: []});
+        });
         uppy.on('upload-success', (file, response) => {
             const metadata = file.meta;
             const d = new Date(metadata.lastModified);
             const pad = (num) => String(num).padStart(2, '0');
             const dFormatted = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
             const now = new Date(Date.now());
             const nowFormatted = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
             let newReqObj, hubTxtObj, parentDirObj;
             let hubType = metadata.hubType || "trackHub";
             newReqObj = {
                 "fileName": cgiEncode(metadata.fileName),
                 "fileSize": metadata.fileSize,
                 "fileType": metadata.fileType,
                 "genome": metadata.genome,
                 "parentDir": cgiEncode(metadata.parentDir),