616c17576bf1e99f9452a8b64fc62121b3fd19db
chmalee
  Wed Aug 5 12:39:46 2026 -0700
Refuse a batch that would put more than one genome in a hub we build, refs #37998

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git src/hg/js/hgMyData.js src/hg/js/hgMyData.js
index 04496028b2c..8513d758532 100644
--- src/hg/js/hgMyData.js
+++ src/hg/js/hgMyData.js
@@ -545,30 +545,45 @@
             file.meta.fileSize = file.size;
             file.meta.lastModified = file.data.lastModified;
             thisQuota += file.size;
 
         }
         // If any files will overwrite existing ones, show a single confirmation dialog
         if (filesToOverwrite.length > 0) {
             let fileNames = filesToOverwrite.map(f => f.meta.name).join("\n  ");
             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");
             }
         }
+        // A hub we synthesize gets one genome line, so everything going into it has to
+        // agree. Runs after the loop above, which trims parentDir, stamps a 2bit's
+        // genome onto its siblings and adopts an existing assembly hub's genome, so
+        // this sees the values the server will. A batch bringing its own hub.txt
+        // states its own genomes, and hubtools does not come through here at all
+        if (!isSplitHub && !hubTxtInBatch) {
+            for (let m of hubsWithMixedGenomes(Object.values(files))) {
+                uppy.info(`Error: the hub "${m.hub}" would hold files for more than ` +
+                    `one genome (${m.genomes.join(", ")}). The hub.txt this page ` +
+                    `writes for you can only name one genome. Give each genome its ` +
+                    `own hub name, or include your own hub.txt. hubtools can upload ` +
+                    `a hub covering several genomes.`, "error", 10000);
+                doUpload = false;
+            }
+        }
         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;
@@ -589,30 +604,115 @@
     let segments = rel.split("/");
     segments.pop(); // drop the filename
     return segments.join("/");
 }
 
 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 genomesInHub(hub) {
+    // Genomes already stored under this hub, so a later upload cannot slip a second
+    // genome into a hub that was built for one
+    let found = [];
+    for (let row of hubCreate.uiState.fileList || []) {
+        if (row.fullPath !== hub && !row.fullPath.startsWith(hub + "/")) {
+            continue;
+        }
+        if (row.genome && !found.includes(row.genome)) {
+            found.push(row.genome);
+        }
+    }
+    return found;
+}
+
+function hubsWithMixedGenomes(fileList) {
+    // Return [{hub, genomes}] for every hub that would end up holding more than one
+    // genome, counting both what is already stored and what this batch adds.
+    // Grouped by the first path segment, so per-genome subdirectories of one hub
+    // count together. writeHubText gives a synthesized hub.txt a single genome line
+    // and later files only append a track stanza, so it cannot describe them all.
+    // Object.create(null) because a hub may be named 'constructor' or 'toString'
+    let byHub = Object.create(null);
+    let storedCount = Object.create(null);
+    for (let f of fileList) {
+        // trim to match normalizeParentDir, or a stray space makes its own hub
+        let hub = (((f.meta && f.meta.parentDir) || "").trim()).split("/")[0];
+        let genome = (f.meta && f.meta.genome) || "";
+        if (!hub || !genome) {
+            continue;
+        }
+        if (!(hub in byHub)) {
+            let stored = genomesInHub(hub);
+            byHub[hub] = stored.slice();
+            storedCount[hub] = stored.length;
+        }
+        if (!byHub[hub].includes(genome)) {
+            byHub[hub].push(genome);
+        }
+    }
+    let mixed = [];
+    for (let hub of Object.keys(byHub)) {
+        // a hub already holding several genomes came from a hub.txt of the user's
+        // own or from hubtools, so it is not ours to refuse
+        if (storedCount[hub] > 1) {
+            continue;
+        }
+        if (byHub[hub].length > 1) {
+            mixed.push({hub: hub, genomes: byHub[hub]});
+        }
+    }
+    return mixed;
+}
+
+// The last mixed-genome warning shown, so saving a file card repeatedly does not
+// repeat it. Uppy's Informer keys its list on the message text
+let lastMixedGenomeWarning = "";
+
+function warnOnMixedGenomes(uppyInstance) {
+    // Say something as soon as the user picks the genomes, rather than leaving it to
+    // the error onBeforeUpload raises
+    let fileList = uppyInstance.getFiles();
+    let descriptor = hubCreate.getLastHubBatchDescriptor();
+    if ((descriptor && descriptor.isSplit) ||
+            fileList.some(looksLikeHubTxt) ||
+            fileList.some(f => f.meta && f.meta.batchSplitHub === "true")) {
+        return;
+    }
+    let mixed = hubsWithMixedGenomes(fileList);
+    if (!mixed.length) {
+        lastMixedGenomeWarning = "";
+        return;
+    }
+    let m = mixed[0];
+    let msg = `The hub "${m.hub}" now has files for ${m.genomes.join(", ")}. ` +
+        `The hub.txt this page writes for you can only name one genome, so give ` +
+        `each genome its own hub name before uploading. Your own hub.txt, or ` +
+        `hubtools, can cover several genomes.`;
+    if (msg === lastMixedGenomeWarning) {
+        return;
+    }
+    lastMixedGenomeWarning = msg;
+    uppyInstance.info(msg, "warning", 10000);
+}
+
 let hubBatchParsesInFlight = 0;
 function setUploadButtonEnabled(enabled) {
     // Pauses uploads while parseHubBatch is running so pre-finish sees stamped meta.
     let btn = document.querySelector(".uppy-StatusBar-actionBtn--upload");
     if (!btn) return;
     btn.disabled = !enabled;
     btn.style.opacity = enabled ? "" : "0.5";
     btn.style.cursor = enabled ? "" : "wait";
     btn.title = enabled ? "" : "Parsing hub definition...";
 }
 
 function applySplitHubDescriptor(uppyInstance, descriptor) {
     // Stamp per-file genome from the descriptor and flag the batch as split.
     let hubFile = descriptor.hubFile;
     let hubParentDir = hubFile && hubFile.meta && hubFile.meta.parentDir;
@@ -948,30 +1048,32 @@
             // the user's own name outranks anything a hub.txt asks for later
             userSetBatchHubName = true;
             for (let [key, file] of Object.entries(files)) {
                 // Swap only the root segment; preserve any per-genome
                 // subdirectory the user supplied via a folder drop.
                 let oldParent = (file.meta && file.meta.parentDir) || "";
                 let segments = oldParent.split("/");
                 let newParent;
                 if (segments.length > 1) {
                     newParent = newRoot + "/" + segments.slice(1).join("/");
                 } else {
                     newParent = newRoot;
                 }
                 this.uppy.setFileMeta(file.id, {parentDir: newParent});
             }
+            // merging separate hubs under one name can bring two genomes together
+            warnOnMixedGenomes(this.uppy);
         });
 
         batchSelectDiv.appendChild(batchParentDirLabel);
         batchSelectDiv.appendChild(batchParentDirInput);
 
         // append the batch changes to the bottom of the file list, for some reason
         // I can't append to the actual Dashboard-files, it must be getting emptied
         // and re-rendered or something
         let uppyFilesDiv = document.querySelector(".uppy-Dashboard-progressindicators");
         if (uppyFilesDiv) {
             uppyFilesDiv.insertBefore(batchSelectDiv, uppyFilesDiv.firstChild);
         }
         refreshBatchHubNameInput(this.uppy);
 
         // autocomplete only applies in the track-hub path
@@ -1141,30 +1243,31 @@
             // check the filename and hubname metadata and warn the user
             // to edit them if they are wrong. unfortunately I cannot
             // figure out how to force the file card to re-toggle
             // and jump back into the editor from here
             if (file) {
                 let fileNameMatch = file.meta.name.match(fileNameRegex);
                 if (!fileNameMatch || fileNameMatch[0] !== file.meta.name) {
                     uppy.info(`Error: File name has special characters, please rename file: '${file.meta.name}' to only include alpha-numeric characters, period, or underscore.`, 'error', 5000);
                 }
                 if (!isValidParentDir(normalizeParentDir(file))) {
                     uppy.info(`Error: Hub path '${file.meta.parentDir}' must be alpha-numeric / period / underscore segments separated by '/'.`, 'error', 5000);
                 }
             }
             // a hub name edited on a file card has to reach the batch box too
             refreshBatchHubNameInput(this.uppy);
+            warnOnMixedGenomes(this.uppy);
         });
     }
     uninstall() {
         // not really used because we aren't ever uninstalling the uppy instance
         this.uppy.off("file-added");
     }
 }
 
 var hubCreate = (function() {
     let uiState = { // our object for keeping track of the current UI and what to do
         userUrl: "", // the web accesible path where the uploads are stored for this user
         hubNameDefault: "",
         currentHub: "", // if the user has a hub dir open, set the name here and use it as the default
                         // hub name when uploading a new file with the dir open, otherwise hubNameDefault
         currentHubPath: "", // full path of the open dir, so we can tell which hub it belongs to