25a03671f5c95936def6417c86b830e214d51d76
chmalee
  Wed Aug 5 11:38:27 2026 -0700
Name the hub from the 2bit or hub.txt, and keep the batch inputs in step with the files, refs #37972

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

diff --git src/hg/js/hgMyData.js src/hg/js/hgMyData.js
index 8e1f0709b9d..04496028b2c 100644
--- src/hg/js/hgMyData.js
+++ src/hg/js/hgMyData.js
@@ -65,30 +65,98 @@
 function initAutocompleteForInput(inpIdStr, selectEle) {
     // we must set up the autocompleteCat for each input created, once per file chosen
     // override the autocompleteCat.js _renderMenu to get the menu on top
     // of the uppy widget.
     // Return true if we actually set up the autocomplete, false if we have already
     // set it up previously
     if ( !(inpIdStr in autocompletes) || autocompletes[inpIdStr] === false) {
         let selectFunction = setDbSelectFromAutocomplete.bind(null, selectEle);
         initSpeciesAutoCompleteDropdown(inpIdStr, selectFunction, null, null, null, onSearchError);
         autocompletes[inpIdStr] = true;
         return true;
     }
     return false;
 }
 
+function removeBatchSelectDiv() {
+    // Take down the batch controls. The autocomplete memo is keyed by input id, so
+    // it has to be cleared alongside the div; a rebuilt search bar reuses the same
+    // id and initAutocompleteForInput would skip it
+    let div = document.getElementById("batch-selector-div");
+    if (div) {
+        autocompletes.batchDbSearchBar = false;
+        div.remove();
+    }
+}
+
+// Set once the user types a hub name in the batch box, so a hub.txt parsed after
+// that does not take the name back off them. Cleared when the batch empties
+let userSetBatchHubName = false;
+
+function applyHubTxtHubName(uppyInstance, descriptor) {
+    // A hub.txt names the directory its hub lives in, so use that as the hubSpace
+    // hub name. Only the first path segment is swapped, so a folder drop keeps
+    // whatever subdirectories it came with
+    if (userSetBatchHubName) {
+        return;
+    }
+    let raw = descriptor && descriptor.hubMeta ? descriptor.hubMeta.hubName : null;
+    let hubRoot = hubCreate.sanitizeHubName(raw);
+    if (!hubRoot) {
+        return;
+    }
+    if (raw.trim() !== hubRoot) {
+        uppyInstance.info(`Using "${hubRoot}" as the hub name. The name "${raw.trim()}" ` +
+            `in hub.txt has characters that cannot be used in a directory name.`,
+            "info", 6000);
+    }
+    if (hubRoot in hubCreate.uiState.filesHash) {
+        uppyInstance.info(`These files will be added to your existing hub "${hubRoot}", ` +
+            `named by the hub.txt in this upload.`, "warning", 8000);
+    }
+    for (let f of uppyInstance.getFiles()) {
+        let segments = ((f.meta && f.meta.parentDir) || "").split("/");
+        let newParent;
+        if (segments.length > 1) {
+            newParent = hubRoot + "/" + segments.slice(1).join("/");
+        } else {
+            newParent = hubRoot;
+        }
+        uppyInstance.setFileMeta(f.id, {parentDir: newParent});
+    }
+    refreshBatchHubNameInput(uppyInstance);
+}
+
+function refreshBatchHubNameInput(uppyInstance) {
+    // Point the batch Hub Name box at the hub the files are really set to. Leaves
+    // the box alone when the batch spans more than one hub
+    let input = document.getElementById("batchParentDir");
+    if (!input) {
+        return;
+    }
+    let roots = [];
+    for (let f of uppyInstance.getFiles()) {
+        let root = ((f.meta && f.meta.parentDir) || "").split("/")[0];
+        if (root && !roots.includes(root)) {
+            roots.push(root);
+        }
+    }
+    if (roots.length === 1) {
+        input.value = roots[0];
+    }
+}
+
 function generateApiKey() {
     let apiKeyInstr = document.getElementById("apiKeyInstructions");
     let apiKeyDiv = document.getElementById("apiKey");
 
     if (!document.getElementById("spinner")) {
         let spinner = document.createElement("i");
         spinner.id = "spinner";
         spinner.classList.add("fa", "fa-spinner", "fa-spin");
         document.getElementById("generateApiKey").after(spinner);
     }
 
     let handleSuccess = function(reqObj) {
         apiKeyDiv.textContent = reqObj.apiKey;
         apiKeyInstr.style.display = "block";
         let revokeDiv= document.getElementById("revokeDiv");
@@ -324,30 +392,34 @@
                                 $(selector).autocompleteCat("search", inp);
                             });
                 }
             }
             return ret;
             }
         },
         {
             id: 'parentDir',
             name: 'Hub Name',
         }];
         return fields;
     },
     doneButtonHandler: function() {
         uppy.clear();
+        // uppy.clear only resets state, it emits no file-removed, so the batch
+        // controls would otherwise survive into the next batch
+        removeBatchSelectDiv();
+        userSetBatchHubName = false;
     },
 };
 
 // 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
 
         // Split hubs (genomesFile= with multiple genomes) can carry multiple 2bits.
         let cachedDescriptor = hubCreate.getLastHubBatchDescriptor();
@@ -372,34 +444,48 @@
                       `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.
         let batchTwoBit = twoBitsInBatch[0];
         if (batchTwoBit && !isSplitHub) {
             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;
             }
+            // Every file in the batch takes its hub root from the hub-defining
+            // file, which is the hub.txt when the user supplied one. Editing the
+            // hub name on a file card changes only that file's meta
+            let hubDefiner = Object.values(files).find(looksLikeHubTxt) || batchTwoBit;
+            let asmHubRoot = (hubDefiner.meta.parentDir || "").trim().split("/")[0];
             for (let f of Object.values(files)) {
                 f.meta.genome = asmGenome;
                 f.meta.genomeLabel = asmGenome;
                 f.meta.hubType = "assemblyHub";
+                if (asmHubRoot) {
+                    // swap the first segment only, a folder drop keeps its subdirectory
+                    let segments = (f.meta.parentDir || "").split("/");
+                    if (segments.length > 1) {
+                        f.meta.parentDir = asmHubRoot + "/" + segments.slice(1).join("/");
+                    } else {
+                        f.meta.parentDir = asmHubRoot;
+                    }
+                }
                 // 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)) {
             f.meta.batchHasHubTxt = hasHubTxt ? "true" : "false";
         }
 
         for (let [key, file] of Object.entries(files)) {
@@ -589,61 +675,65 @@
         let syncParentDir = hubDefiner && hubDefiner.meta && hubDefiner.meta.parentDir;
         // Folder drops carry their own multi-segment parentDir; don't overwrite.
         let isNestedLayout = uppyInstance.getFiles().some(
             f => f.meta && f.meta.parentDir && f.meta.parentDir.includes("/"));
         for (let f of uppyInstance.getFiles()) {
             let isHubDefining = looksLikeTwoBit(f) || looksLikeHubTxt(f);
             let meta = {
                 genome: genome,
                 genomeLabel: genome,
                 hubType: "assemblyHub",
                 genomeLocked: !isHubDefining || alsoLockHubDefiners,
             };
             if (syncParentDir && !isNestedLayout) meta.parentDir = syncParentDir;
             uppyInstance.setFileMeta(f.id, meta);
         }
+        // keep the batch Hub Name box showing where the files are really going
+        refreshBatchHubNameInput(uppyInstance);
     }
 
     if (hubTxt) {
         hubBatchParsesInFlight++;
         setUploadButtonEnabled(false);
         hubCreate.parseHubBatch(uppyInstance.getFiles()).then((descriptor) => {
             // Skip stale parses; only the latest-completed one applies.
             if (descriptor !== hubCreate.getLastHubBatchDescriptor()) return;
             for (let e of descriptor.errors) {
                 uppyInstance.info(e, "error", 8000);
             }
             for (let w of descriptor.warnings) {
                 uppyInstance.info(w, "warning", 6000);
             }
             if (descriptor.isSplit) {
                 applySplitHubDescriptor(uppyInstance, descriptor);
-                return;
-            }
+            } else {
                 // Single-file hub: hub.txt is authoritative for the one genome
                 // it declares. Lock all siblings to that genome.
                 let parsed = descriptor.hubMeta || {};
                 if (parsed.isAssemblyHub && parsed.genome) {
                     applyGenomeToSiblings(parsed.genome, true);
                     uppyInstance.info(`Using genome "${parsed.genome}" from hub.txt`, "info", 4000);
                 } else if (parsed.genome && twoBit) {
                     let twoBitGenome = twoBit.meta.genome || hubCreate.sanitizeGenomeName(twoBit.name);
                     if (parsed.genome !== twoBitGenome) {
                         applyGenomeToSiblings(parsed.genome, true);
                         uppyInstance.info(`Using genome "${parsed.genome}" from hub.txt (overrides 2bit default)`, "warning", 5000);
                     }
                 }
+            }
+            // last, so it wins over the parentDir the other two stamp
+            applyHubTxtHubName(uppyInstance, descriptor);
         }).catch((err) => {
             console.warn("Could not read hub.txt for genome detection:", err);
         }).finally(() => {
             hubBatchParsesInFlight--;
             if (hubBatchParsesInFlight === 0) setUploadButtonEnabled(true);
         });
         return;
     }
 
     let asmGenome = twoBit.meta.genome || hubCreate.sanitizeGenomeName(twoBit.name);
     applyGenomeToSiblings(asmGenome, false);
 }
 
 // create a custom uppy plugin to batch change the type and db fields
 class BatchChangePlugin extends Uppy.BasePlugin {
@@ -671,61 +761,70 @@
         let fileDiv = document.getElementById(id);
         // this might not exist yet depending on where we are in the render cycle
         if (fileDiv) {
             let dbSelectId = "db_select_" + file.id;
             if (!document.getElementById(dbSelectId)) {
                 let dbSelect = document.createElement("select");
                 dbSelect.id = dbSelectId;
                 let dbOpts = hubCreate.makeGenomeSelectOptions();
                 this.createOptsForSelect(dbSelect, dbOpts);
                 fileDiv.appendChild(dbSelect);
             }
         }
     }
 
     removeBatchSelectsFromDashboard() {
-        let batchSelectDiv = document.getElementById("batch-selector-div");
-        if (batchSelectDiv) {
-            batchSelectDiv.remove();
-        }
+        removeBatchSelectDiv();
     }
 
     addBatchSelectsToDashboard() {
-        if (!document.getElementById("batch-selector-div")) {
         // If the batch contains a 2bit, the UCSC genome picker makes no
         // sense - show the custom genome name read-only instead. Detect by
         // filename rather than meta.hubType because setFileMeta updates
         // Uppy state immutably and the meta may not be visible on file
         // objects captured from getFiles() earlier in this event. A split
         // assembly hub can declare more than one 2bit (one per genome);
         // join all of them.
         let assemblyHubGenomes = [];
         for (let f of this.uppy.getFiles()) {
             if (looksLikeTwoBit(f)) {
                 let g = f.meta.genome || hubCreate.sanitizeGenomeName(f.name);
                 if (g && !assemblyHubGenomes.includes(g)) {
                     assemblyHubGenomes.push(g);
                 }
             }
         }
+        // The genome row is built one way for an assembly hub and another for a
+        // track hub, so a 2bit joining or leaving an existing batch has to
+        // rebuild the whole thing rather than leave the old row in place
+        let asmSignature = assemblyHubGenomes.join(", ");
+        let staleDiv = document.getElementById("batch-selector-div");
+        if (staleDiv) {
+            if (staleDiv.dataset.asmGenome === asmSignature) {
+                refreshBatchHubNameInput(this.uppy);
+                return;
+            }
+            removeBatchSelectDiv();
+        }
         let assemblyHubGenome = null;
         if (assemblyHubGenomes.length) {
             assemblyHubGenome = assemblyHubGenomes.join(", ");
         }
 
         let batchSelectDiv = document.createElement("div");
         batchSelectDiv.id = "batch-selector-div";
+        batchSelectDiv.dataset.asmGenome = asmSignature;
         batchSelectDiv.style.display = "grid";
         batchSelectDiv.style.width = "80%";
         // the grid syntax is 2 columns, 3 rows
         batchSelectDiv.style.gridTemplateColumns = "max-content minmax(0, 200px) max-content 1fr min-content";
         batchSelectDiv.style.gridTemplateRows = "repest(3, auto)";
         batchSelectDiv.style.margin = "10px auto"; // centers this div
         batchSelectDiv.style.fontSize = "14px";
         batchSelectDiv.style.gap = "8px";
         if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
             batchSelectDiv.style.color = "#eaeaea";
         }
 
         // first just explanatory text:
         let batchSelectText = document.createElement("div");
         batchSelectText.textContent = "Change options for all files:";
@@ -825,77 +924,80 @@
             batchSelectDiv.appendChild(batchDbLabel);
             batchSelectDiv.appendChild(batchDbSelect);
             batchSelectDiv.appendChild(batchDbSearchBarLabel);
             batchSelectDiv.appendChild(batchDbGenomeSearchBar);
             batchSelectDiv.appendChild(batchDbGenomeSearchButton);
         }
 
         // the batch change hub name (shown in both modes)
         let batchParentDirLabel = document.createElement("label");
         batchParentDirLabel.textContent = "Hub Name";
         batchParentDirLabel.for = "batchParentDir";
         batchParentDirLabel.style.gridArea = "3 / 1 / 3 / 1";
 
         let batchParentDirInput = document.createElement("input");
         batchParentDirInput.id = "batchParentDir";
+        // refreshBatchHubNameInput replaces this with the files' own hub below
         batchParentDirInput.value = hubCreate.getDefaultHubName();
         batchParentDirInput.style.gridArea = "3 / 2 / 3 / 2";
         batchParentDirInput.style.margin= "1px 1px auto";
         batchParentDirInput.classList.add("uppy-u-reset", "uppy-c-textInput");
 
         batchParentDirInput.addEventListener("change", (ev) => {
             let files = this.uppy.getFiles();
             let newRoot = ev.target.value;
+            // 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});
             }
         });
 
         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
         if (batchDbSelect && batchDbGenomeSearchBar && batchDbGenomeSearchButton) {
-                let justInitted = initAutocompleteForInput(batchDbGenomeSearchBar.id, batchDbSelect);
-                if (justInitted) {
+            initAutocompleteForInput(batchDbGenomeSearchBar.id, batchDbSelect);
+            // this button belongs to the element just built, so it is bound
+            // every time, unlike the autocomplete which is memoized by id
             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) => {
             // Reject a duplicate 2bit only when there's no hub.txt in the batch:
             // a folder drop or a manual pick of hub.txt + multi-genome
             // genomes.txt + several 2bits is legitimate; we can't know that
             // synchronously here, so defer to the pre-finish hook (which has
             // the parseHubBatch result).
             let droppedFromFolder = !!parentDirFromRelativePath(file);
             let batchHasHubTxt = this.uppy.getFiles().some(looksLikeHubTxt);
             if (looksLikeTwoBit(file) && !droppedFromFolder && !batchHasHubTxt) {
                 let existingTwoBits = this.uppy.getFiles().filter(
                     f => f.id !== file.id && looksLikeTwoBit(f) &&
                          !parentDirFromRelativePath(f));
                 if (existingTwoBits.length > 0) {
@@ -956,51 +1058,77 @@
             propagateAssemblyHubMeta(this.uppy);
 
             if (this.uppy.getFiles().length > 1) {
                 this.addBatchSelectsToDashboard();
             } else {
                 // only open the file editor when there is one file
                 const dash = uppy.getPlugin("Dashboard");
                 dash.toggleFileCard(true, file.id);
             }
         });
         this.uppy.on("file-removed", (file) => {
             // remove the batch change selects if now <2 files present
             if (this.uppy.getFiles().length < 2) {
                 this.removeBatchSelectsFromDashboard();
             }
+            if (this.uppy.getFiles().length === 0) {
+                userSetBatchHubName = false;
+            }
             // If a hub-definition file leaves the batch, the cached split-hub
             // descriptor is no longer valid. Clear the cache and the per-file
             // stamps so pre-finish re-evaluates from scratch.
             if (looksLikeHubTxt(file) ||
                 (file.meta && file.meta.fileName === "genomes.txt")) {
                 hubCreate.clearLastHubBatchDescriptor();
                 for (let f of this.uppy.getFiles()) {
                     if (f.meta && f.meta.batchSplitHub === "true") {
                         this.uppy.setFileMeta(f.id, {
                             batchSplitHub: undefined,
                             genomeLocked: false,
                         });
                     }
                 }
                 propagateAssemblyHubMeta(this.uppy);
             }
+            // The last 2bit leaving takes the assembly hub with it, so let the
+            // siblings it stamped go back to being ordinary track files. A hub.txt
+            // still in the batch defines the hub on its own, so leave those alone
+            if (looksLikeTwoBit(file) &&
+                    !this.uppy.getFiles().some(looksLikeTwoBit) &&
+                    !this.uppy.getFiles().some(looksLikeHubTxt)) {
+                for (let f of this.uppy.getFiles()) {
+                    // a file headed into an existing assembly hub keeps its lock,
+                    // that came from the destination and not from the 2bit
+                    let dest = hubCreate.uiState.filesHash[f.meta && f.meta.parentDir];
+                    if (dest && dest.hubType === "assemblyHub") {
+                        continue;
+                    }
+                    this.uppy.setFileMeta(f.id, {
+                        hubType: "trackHub",
+                        genomeLocked: false,
+                    });
+                }
+            }
+            if (this.uppy.getFiles().length > 1) {
+                // rebuilds only if the batch changed shape, see the signature check
+                this.addBatchSelectsToDashboard();
+            }
         });
 
         this.uppy.on("dashboard:modal-open", () => {
             // check if there were already files chosen from before:
-            if (this.uppy.getFiles().length > 2) {
+            if (this.uppy.getFiles().length > 1) {
                 this.addBatchSelectsToDashboard();
             }
             if (this.uppy.getFiles().length < 2) {
                 this.removeBatchSelectsFromDashboard();
             }
         });
         this.uppy.on("dashboard:modal-closed", () => {
             if (this.uppy.getFiles().length < 2) {
                 this.removeBatchSelectsFromDashboard();
             }
             let allFiles = this.uppy.getFiles();
             let completeFiles = this.uppy.getFiles().filter((f) => f.progress.uploadComplete === true);
             if (allFiles.length === completeFiles.length) {
                 this.uppy.clear();
             }
@@ -1011,30 +1139,32 @@
 
         this.uppy.on("dashboard:file-edit-complete", (file) => {
             // 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);
         });
     }
     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
@@ -1081,30 +1211,41 @@
         return path ? path.split("/")[0] : "";
     }
 
     function sanitizeGenomeName(name) {
         // Strip .2bit, replace non-alphanumeric/_/-/. with _, drop hub_ prefix.
         // Returns empty string if nothing usable is left.
         // The allowed character class [A-Za-z0-9._-] must match the
         // server-side check in src/hg/hgHubConnect/hooks/pre-finish.c.
         if (!name) return "";
         let stem = name.replace(/\.2bit$/i, "");
         stem = stem.replace(/[^A-Za-z0-9._-]/g, "_");
         stem = stem.replace(/^hub_/, "");
         return stem;
     }
 
+    function sanitizeHubName(name) {
+        // Turn the hub.txt 'hub' line into a name usable as a hubSpace directory.
+        // The allowed characters are the ones isValidParentDir accepts in one path
+        // segment, a narrower set than sanitizeGenomeName permits, so these two
+        // cannot share an implementation. Returns empty string if nothing is left.
+        if (!name) return "";
+        let clean = name.trim().replace(/[^A-Za-z0-9._]/g, "_");
+        if (clean === "." || clean === "..") return "";
+        return clean;
+    }
+
     function hubTxtPathForHub(hubName) {
         // Return the fullPath of the hub.txt file inside hubName as recorded in
         // hubSpace, falling back to "hubName/hub.txt" if there's no row yet.
         // The user may have uploaded their own "araTha1.hub.txt" - use its
         // actual filename rather than assuming "hub.txt".
         let dir = uiState.filesHash[hubName];
         if (dir && dir.children) {
             for (let child of dir.children) {
                 if (child.fileType === "hub.txt") return child.fullPath;
             }
         }
         return hubName + "/hub.txt";
     }
 
     function assemblyHubByGenome(genome) {
@@ -1135,36 +1276,39 @@
             if (line.startsWith("#")) continue;
             let sp = line.indexOf(" ");
             let tab = line.indexOf("\t");
             let split = (sp === -1) ? tab : (tab === -1 ? sp : Math.min(sp, tab));
             if (split === -1) continue;
             let key = line.substring(0, split);
             let value = line.substring(split + 1).trim();
             if (!current) current = {};
             if (!(key in current)) current[key] = value;
         }
         if (current) stanzas.push(current);
         return stanzas;
     }
 
     function parseHubTxt(text) {
-        // Returns {genome, twoBitPath, isAssemblyHub, genomesFile, useOneFile}.
+        // Returns {genome, twoBitPath, isAssemblyHub, genomesFile, useOneFile, hubName}.
         let ret = {genome: null, twoBitPath: null, isAssemblyHub: false,
-                   genomesFile: null, useOneFile: false};
+                   genomesFile: null, useOneFile: false, hubName: null};
         if (!text) return ret;
         let stanzas = parseRaSettings(text);
         let hub = stanzas[0] || {};
+        // the hub setting names the directory the hub lives in, see the hub.txt
+        // description in hgTrackHubHelp.html
+        if (hub.hub) ret.hubName = hub.hub;
         if (hub.genome) ret.genome = hub.genome;
         if (hub.twoBitPath) {
             ret.twoBitPath = hub.twoBitPath;
             ret.isAssemblyHub = true;
         }
         if (hub.genomesFile) ret.genomesFile = hub.genomesFile;
         if (hub.useOneFile && hub.useOneFile.toLowerCase() === "on") {
             ret.useOneFile = true;
         }
         // useOneFile hubs put `genome` in later stanzas.
         if (!ret.genome) {
             for (let s of stanzas) {
                 if (s.genome) { ret.genome = s.genome; break; }
             }
         }
@@ -2665,25 +2809,26 @@
                 cart.send({ getHubSpaceUIState: {}}, handleRefreshState, handleErrorState);
                 cart.flush();
             } else {
                 showExistingFiles([]);
             }
         }
     }
 
     return { init: init,
              uiState: uiState,
              defaultDb: defaultDb,
              makeGenomeSelectOptions: makeGenomeSelectOptions,
              getDefaultHubName: getDefaultHubName,
              detectFileType: detectFileType,
              sanitizeGenomeName: sanitizeGenomeName,
+             sanitizeHubName: sanitizeHubName,
              readFileAsText: readFileAsText,
              parseHubTxt: parseHubTxt,
              parseHubBatch: parseHubBatch,
              getLastHubBatchDescriptor: getLastHubBatchDescriptor,
              clearLastHubBatchDescriptor: clearLastHubBatchDescriptor,
              firstAssemblyHub: firstAssemblyHub,
              genomeIsAssemblyHub: genomeIsAssemblyHub,
              assemblyHubByGenome: assemblyHubByGenome,
            };
 }());