37aae4a2c5a919c329378f3da0792e168426537d
chmalee
  Tue Aug 11 11:31:52 2026 -0700
hgMyData: a blank hub genome means unset, not a mismatch, refs #37964

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

diff --git src/hg/js/hgMyData.js src/hg/js/hgMyData.js
index 61279a1f70d..9cc198a57b4 100644
--- src/hg/js/hgMyData.js
+++ src/hg/js/hgMyData.js
@@ -1,3047 +1,3052 @@
 /* jshint esversion: 8 */
 var debugCartJson = true;
 
 function prettyFileSize(num) {
     if (!num) {return "0B";}
     if (num < (1024 * 1024)) {
         return `${(num/1024).toFixed(1)}KB`;
     } else if (num < (1024 * 1024 * 1024)) {
         return `${((num/1024)/1024).toFixed(1)}MB`;
     } else {
         return `${(((num/1024)/1024)/1024).toFixed(1)}GB`;
     }
 }
 
 function renderTimeCell(data, type) {
     // DataTables renderer for the two time columns. The server sends seconds since
     // the epoch, so the reader sees their own timezone rather than the server's,
     // while ordering stays on the number
     if (type !== "display") {
         return data;
     }
     if (!data) {
         return "";
     }
     return new Date(data * 1000).toLocaleString();
 }
 
 function cgiEncode(value) {
     // copy of cheapgi.c:cgiEncode except we are explicitly leaving '/' characters, and
     // space becomes '+':
     let splitVal = value.split('/');
     splitVal.forEach((ele, ix) => {
         if (ele == " ") {
             splitVal[ix] = '+';
         } else {
             splitVal[ix] = encodeURIComponent(ele);
         }
     });
     return splitVal.join('/');
 }
 
 function cgiDecode(value) {
     // decode an encoded value
     return decodeURIComponent(value);
 }
 
 function setDbSelectFromAutocomplete(selectEle, item) {
     // this has been bound to the <select> we are going to add
     // a new child option to
     if (item.disabled || !item.genome) return;
     let newOpt = document.createElement("option");
     newOpt.value = item.genome;
     newOpt.label = item.label;
     newOpt.selected = true;
     selectEle.appendChild(newOpt);
     const event = new Event("change");
     selectEle.dispatchEvent(event);
 }
 
 function onSearchError(jqXHR, textStatus, errorThrown, term) {
     return [{label: 'No genomes found', value: '', genome: '', disabled: true}];
 }
 
 let autocompletes = {};
 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);
     refreshBatchSelects(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 refreshBatchSelects(uppyInstance) {
     // Rebuild the batch controls so the genome box shows what the files actually
     // carry. addBatchSelectsToDashboard only rebuilds when the batch changed shape,
     // so this is cheap to call after anything that restamps genome metadata
     let plugin = uppyInstance.getPlugin("BatchChangePlugin");
     if (plugin && uppyInstance.getFiles().length > 1) {
         plugin.addBatchSelectsToDashboard();
     }
 }
 
 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");
         revokeDiv.style.display = "block";
         document.getElementById("spinner").remove();
 
         // remove the word 'already' from the message if we have just re-generated a key
         let refreshSpan = document.getElementById("removeOnGenerate");
         if (refreshSpan) {
             refreshSpan.style.display = "none";
         }
     };
 
     let cartData = {generateApiKey: {}};
     cart.setCgiAndUrl(fileListEndpoint);
     cart.send(cartData, handleSuccess);
     cart.flush();
 }
 
 function revokeApiKeys() {
     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("revokeApiKeys").after(spinner);
     }
 
     let handleSuccess = function(req) {
         apiKeyInstr.style.display = "none";
         document.getElementById("spinner").remove();
         let generateDiv = document.getElementById("generateDiv");
         generateDiv.style.display = "block";
         let revokeDiv = document.getElementById("revokeDiv");
         revokeDiv.style.display = "none";
     };
 
     let cartData = {revokeApiKey: {}};
     cart.setCgiAndUrl(fileListEndpoint);
     cart.send(cartData, handleSuccess);
     cart.flush();
 }
 
 const fileNameRegex = /[0-9a-zA-Z._]+/g; // allowed characters in file names
 const fileNameFixRegex = /[^0-9a-zA-Z_]+/g; // '.' get replaced to underbars in trackHub.c. Also any files uploaded from hubtools that may have weird chars need to be escaped
 const parentDirSegmentRegex = /^[0-9a-zA-Z._]+$/; // allowed characters in each hub-path segment
 
 function normalizeParentDir(file) {
     // Strip surrounding whitespace off a file's parentDir, writing the trimmed value back
     // into the file metadata. A trailing space is invisible in the hub name field, so
     // rejecting it outright gives the user an error they cannot see the cause of. Must be
     // called before isValidParentDir so we validate what will actually be uploaded.
     let parentDir = (file.meta && file.meta.parentDir) || "";
     let trimmed = parentDir.trim();
     if (trimmed !== parentDir) {
         uppy.setFileMeta(file.id, {parentDir: trimmed});
         file.meta.parentDir = trimmed;
     }
     return trimmed;
 }
 
 function isValidParentDir(parentDir) {
     // Slash-separated path of segments matching parentDirSegmentRegex; no '..'.
     if (!parentDir) return false;
     if (parentDir.startsWith("/") || parentDir.endsWith("/")) return false;
     let segments = parentDir.split("/");
     for (let seg of segments) {
         if (!seg || seg === "." || seg === "..") return false;
         if (!parentDirSegmentRegex.test(seg)) return false;
     }
     return true;
 }
 
 function getTusdEndpoint() {
     // this variable is set by hgHubConnect and comes from hg.conf value
     return tusdEndpoint;
 }
 
 let uppyOptions = {
     trigger: ".uploadButton",
     showProgressDetails: true,
     note: "The UCSC Genome Browser is not a HIPAA compliant data store. Do not upload patient information or other sensitive data files here, as anyone with the URL can view them.",
     meta: {"genome": null, "fileType": null},
     restricted: {requiredMetaFields: ["genome"]},
     closeModalOnClickOutside: true,
     closeAfterFinish: true,
     theme: 'auto',
     metaFields: (file) => {
         const fields = [{
             id: 'name',
             name: 'File name',
             render: ({value, onChange, required, form}, h) => {
                 return h('input',
                     {type: "text",
                     value: value,
                     class: "uppy-u-reset uppy-c-textInput uppy-Dashboard-FileCard-input",
                     onChange: e => {
                         onChange(e.target.value);
                         file.meta.fileType = hubCreate.detectFileType(e.target.value);
                         file.meta.name = e.target.value;
                     },
                     required: required,
                     form: form,
                     }
                 );
             },
         },
         {
             id: 'genome',
             name: 'Genome',
             render: ({value, onChange}, h) => {
                 // 2bit files name a new assembly hub (editable). Other files
                 // with genomeLocked are pinned by a hub-defining sibling or
                 // the hub they were drilled into.
                 let isTwoBit = file.meta.fileType === "2bit";
                 let isHubTxt = looksLikeHubTxt(file);
                 let isLocked = !!file.meta.genomeLocked;
                 if (isTwoBit || isLocked) {
                     let editable2bit = isTwoBit && !isLocked;
                     let batchHasHubTxt = uppy.getFiles().some(looksLikeHubTxt);
                     let label;
                     if (editable2bit) {
                         label = "Genome name for your assembly hub:";
                     } else if (isHubTxt || batchHasHubTxt) {
                         label = "Genome (locked by hub.txt - edit hub.txt locally and re-add to change):";
                     } else {
                         label = "Genome (locked by this assembly hub):";
                     }
                     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%"
                         },
                     // first child of div
                     "Select from popular assemblies:",
                     // second div child
                     h('select', {
                         id: `${file.meta.name}DbSelect`,
                         style: "margin-left: 5px",
                         onChange: e => {
                             let val = e.target.value;
                             let label = e.target.selectedOptions[0].label;
                             let hub = hubCreate.assemblyHubByGenome(val);
                             // Keep a hub name the user typed or that came from the
                             // folder they opened. A genome from one of their assembly
                             // hubs still moves the file, that hub is the only place
                             // the genome exists.
                             // Read the box rather than file.meta, which the file card
                             // only writes when the card is saved
                             let pdInput = document.getElementById("uppy-Dashboard-FileCard-input-parentDir");
                             let currentParentDir = (pdInput ? pdInput.value :
                                     ((file.meta && file.meta.parentDir) || "")).trim();
                             let userNamedHub = currentParentDir &&
                                     currentParentDir !== hubCreate.uiState.hubNameDefault;
                             let newParentDir;
                             if (hub) {
                                 newParentDir = hub.fullPath;
                             } else if (userNamedHub) {
                                 newParentDir = currentParentDir;
                             } else {
                                 newParentDir = hubCreate.uiState.hubNameDefault;
                             }
                             // we call onChange here, which will do an onChange with a potentially
                             // stale metadata if the user has also edited parentDir. later we will
                             // fix that up and use the genome name as the recommended parentDir
                             // or a pre-existing hub if one exists
                             onChange(val);
                             file.meta.genome = val;
                             file.meta.genomeLabel = label;
                             file.meta.hubType = hub ? "assemblyHub" : "trackHub";
                             file.meta.parentDir = newParentDir;
                             // Sync the Hub Name field in a later tick. In this
                             // tick its onChange would spread the same stale
                             // state as the genome onChange above and revert
                             // genome; deferring lets genome flush first.
                             setTimeout(function() {
                                 let pd = document.getElementById("uppy-Dashboard-FileCard-input-parentDir");
                                 if (pd) {
                                     pd.value = newParentDir;
                                     pd.dispatchEvent(new Event("input", {bubbles: true}));
                                     pd.dispatchEvent(new Event("change", {bubbles: true}));
                                 }
                             }, 0);
                         }
                         },
                         hubCreate.makeGenomeSelectOptions(file.meta.genome, file.meta.genomeLabel).map( (genomeObj) => {
                             return h('option', {
                                 value: genomeObj.value,
                                 label: genomeObj.label,
                                 selected: file.meta.genome !== null ? genomeObj.value === file.meta.genome : genomeObj.value === hubCreate.defaultDb()
                             });
                         })
                     ),
                     h('p', {
                         class: "uppy-Dashboard-FileCard-label",
                         style: "display: block; width: 78%",
                         }, "or search for your genome:"),
                     // third div child
                     h('input', {
                         id: `${file.meta.name}DbInput`,
                         type: 'text',
                         class: "uppy-u-reset uppy-c-textInput uppy-Dashboard-FileCard-input",
                     }),
                     h('input', {
                         id: `${file.meta.name}DbSearchButton`,
                         type: 'button',
                         value: 'search',
                         style: "margin-left: 5px",
                     })
                 );
             let selectToChange = document.getElementById(`${file.meta.name}DbSelect`);
             if (selectToChange) {
                 let justInitted = initAutocompleteForInput(`${file.meta.name}DbInput`, selectToChange);
                 if (justInitted) {
                     // only do this once per file
                     document.getElementById(`${file.meta.name}DbSearchButton`)
                             .addEventListener("click", (e) => {
                                 let inp = document.getElementById(`${file.meta.name}DbInput`).value;
                                 let selector = `[id='${file.meta.name}DbInput']`;
                                 $(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();
         let isSplitHub = (cachedDescriptor && cachedDescriptor.isSplit) ||
                          Object.values(files).some(
                              f => f.meta && f.meta.batchSplitHub === "true");
 
         let hubTxtInBatch = Object.values(files).some(looksLikeHubTxt);
         if (cachedDescriptor && cachedDescriptor.errors.length &&
             (isSplitHub || hubTxtInBatch)) {
             for (let e of cachedDescriptor.errors) {
                 uppy.info(e, "error", 8000);
             }
             return false;
         }
 
         // Single-file hubs synthesize one hub.txt for one genome.
         let twoBitsInBatch = Object.values(files).filter(looksLikeTwoBit);
         if (!isSplitHub && 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.
         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.
             // One 2bit means one hub for the whole batch, so a file whose hub
             // name says otherwise is moved into the 2bit's hub on purpose. Files
             // headed for a different hub belong in their own batch
             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)) {
             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);
                 doUpload = false;
                 continue;
             }
             if (!isValidParentDir(normalizeParentDir(file))) {
                 uppy.info(`Error: Hub path has special characters, please rename hub: ${file.meta.parentDir} for file: ${file.meta.name} to a path of alpha-numeric / period / underscore segments separated by '/'.`, 'error', 5000);
                 doUpload = false;
                 continue;
             }
             // Hub-level files in a split-hub batch intentionally carry empty genome.
             if (!file.meta.genome && file.meta.batchSplitHub !== "true") {
                 uppy.info(`Error: No genome selected for file ${file.meta.name}!`, 'error', 5000);
                 doUpload = false;
                 continue;
             }
             if  (!file.meta.fileType) {
                 uppy.info(`Error: File type not supported, file: ${file.meta.name}!`, 'error', 5000);
                 doUpload = false;
                 continue;
             }
             // check if this hub already exists and the genome is different from what was
-            // just selected, if so, make the user create a new hub
-            if (file.meta.parentDir in hubCreate.uiState.filesHash && hubCreate.uiState.filesHash[file.meta.parentDir].genome !== file.meta.genome) {
+            // just selected, if so, make the user create a new hub. A blank genome means
+            // none has been set yet, not a hub for a genome named "": a directory row is
+            // blank until an upload carrying a genome lands in it, and the hub level
+            // files of a hub that brings its own hub.txt have no genome of their own.
+            // So a blank on either side is not a mismatch.
             let existing = hubCreate.uiState.filesHash[file.meta.parentDir];
+            if (existing && existing.genome && existing.genome !== file.meta.genome) {
                 // If the existing hub is an assembly hub, adopt its genome
                 // automatically rather than erroring - the UI hid the picker
                 // for this case, so the mismatch is just stale metadata.
                 if (existing.hubType === "assemblyHub") {
                     file.meta.genome = existing.genome;
                     file.meta.genomeLabel = existing.genome;
                     file.meta.hubType = "assemblyHub";
-                } else {
+                } else if (file.meta.genome) {
                     uppy.info(`Error: the hub ${file.meta.parentDir} already exists and is for genome "${existing.genome}". Please select the correct genome, a different hub or make a new hub.`, 'error', 10000);
                     doUpload = false;
                     continue;
                 }
             }
             // check if the user is uploading a file that already exists in this hub
             if (file.meta.parentDir in hubCreate.uiState.filesHash) {
                 let hubFiles = hubCreate.uiState.filesHash[file.meta.parentDir].children;
                 for (let j = 0; j < hubFiles.length; j++) {
                     if (hubFiles[j].fileName === file.meta.name) {
                         filesToOverwrite.push(file);
                         break;
                     }
                 }
             }
 
             // Set metadata directly on the file object since we're returning a modified files object
             // (using setFileMeta would be overwritten when we return the files object)
             file.meta.fileName = file.meta.name;
             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 names = filesToOverwrite.map(f => f.meta.name);
             let fileNames = names.join("\n  ");
             if (!confirm(`The following file(s) already exist and will be overwritten:\n  ${fileNames}\n\nContinue?`)) {
                 // the confirm is the only thing that stopped the upload, so say so
                 // rather than leave the Upload button sitting there with no reason
                 uppy.info(`Upload cancelled. It would have overwritten: ${names.join(", ")}. ` +
                           `Rename those files or use a different hub name.`, 'warning', 10000);
                 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.`, 'error', 10000);
             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 parentDirFromRelativePath(file) {
     // Return the directory portion of an Uppy folder-drop file's relative path, or null.
     let rel = (file.data && file.data.webkitRelativePath) ||
               file.relativePath || "";
     if (!rel || !rel.includes("/")) return null;
     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;
     // Nested layouts (per-genome subdirs) carry their own parentDir already.
     let isNestedLayout = uppyInstance.getFiles().some(
         f => f.meta && f.meta.parentDir && f.meta.parentDir.includes("/"));
     for (let f of uppyInstance.getFiles()) {
         let assignedGenome = descriptor.fileGenome.get(f.id);
         let meta = {
             batchSplitHub: "true",
             hubType: descriptor.isAssemblyHub ? "assemblyHub" : "trackHub",
         };
         if (hubParentDir && !isNestedLayout) meta.parentDir = hubParentDir;
         if (assignedGenome) {
             meta.genome = assignedGenome;
             meta.genomeLabel = assignedGenome;
             meta.genomeLocked = true;
         } else if (descriptor.fileGenome.has(f.id)) {
             // Hub-level files (hub.txt, genomes.txt): empty db.
             meta.genome = "";
             meta.genomeLabel = "";
             meta.genomeLocked = true;
         }
         uppyInstance.setFileMeta(f.id, meta);
     }
     refreshBatchSelects(uppyInstance);
     let names = descriptor.genomes.map(g => g.name).join(", ");
     if (names) {
         uppyInstance.info(`Split hub detected. Genomes: ${names}`, "info", 4000);
     }
 }
 
 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.
     //
     // We detect the hub-defining files by filename rather than by meta.fileType,
     // because setFileMeta updates Uppy's state immutably - file objects captured
     // from getFiles() earlier in this event may still carry old meta.
     let files = uppyInstance.getFiles();
     let twoBit = files.find(looksLikeTwoBit);
     let hubTxt = files.find(looksLikeHubTxt);
     if (!twoBit && !hubTxt) {
         hubCreate.clearLastHubBatchDescriptor();
         return;
     }
 
     function applyGenomeToSiblings(genome, alsoLockHubDefiners, hubType) {
         // Set genome/hubType on every file in the batch. Non-hub-defining
         // files (i.e. the sibling tracks) are always locked to this genome so
         // the user can't drift them. The hub-defining files (2bit, hub.txt)
         // are locked only when alsoLockHubDefiners is true - used by the
         // hub.txt path to pin the 2bit's editable field too.
         if (!genome) return;
         // All files in this batch belong to one new hub, so they must share
         // one parentDir. Take it from the hub-defining file - its parentDir
         // came from getDefaultHubName(), while a track that was added first
         // may have been pointed at an existing assembly hub.
         let hubDefiner = hubTxt || twoBit;
         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: hubType,
                 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);
         // and the genome box showing the genome they just picked up
         refreshBatchSelects(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);
             } else {
                 // Single-file hub: hub.txt is authoritative for the one genome
                 // it declares, whether or not it is an assembly hub. Without
                 // this the batch keeps the session's assembly and the rows are
                 // written for a genome the hub.txt never mentions.
                 let parsed = descriptor.hubMeta || {};
                 if (parsed.genome) {
                     let batchHubType = (parsed.isAssemblyHub || twoBit) ? "assemblyHub" : "trackHub";
                     applyGenomeToSiblings(parsed.genome, true, batchHubType);
                     let twoBitGenome = twoBit ?
                         (twoBit.meta.genome || hubCreate.sanitizeGenomeName(twoBit.name)) : null;
                     if (twoBitGenome && parsed.genome !== twoBitGenome) {
                         uppyInstance.info(`Using genome "${parsed.genome}" from hub.txt (overrides 2bit default)`, "warning", 5000);
                     } else {
                         uppyInstance.info(`Using genome "${parsed.genome}" from hub.txt`, "info", 4000);
                     }
                 }
             }
             // 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, "assemblyHub");
 }
 
 // create a custom uppy plugin to batch change the type and db fields
 class BatchChangePlugin extends Uppy.BasePlugin {
     constructor(uppy, opts) {
         super(uppy, opts);
         this.id = "BatchChangePlugin";
         this.type = "progressindicator";
         this.opts = opts;
     }
 
     createOptsForSelect(select, opts) {
         opts.forEach( (opt) => {
             let option = document.createElement("option");
             option.value = opt.value;
             option.label = opt.label;
             option.id = opt.id;
             option.selected = typeof opt.selected !== 'undefined' ? opt.selected : false;
             select.appendChild(option);
         });
     }
 
     addSelectsForFile(file) {
         /* create two selects for the file object, to include the db and type */
         const id = "uppy_" + file.id;
         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() {
         removeBatchSelectDiv();
     }
 
     addBatchSelectsToDashboard() {
         // When the batch's genome is decided for it - by a 2bit, or by a hub.txt
         // that names one - the UCSC picker makes no sense, so show the genome
         // read-only instead. A 2bit is detected 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
         // genome; join all of them.
         // Only a hub-defining file in the batch locks the box. A file drilled into an
         // existing assembly hub also carries genomeLocked, but the user can still
         // retarget that batch at another hub, and then the picker has to come back.
         let lockedGenomes = [];
         let hubDefined = this.uppy.getFiles().some(
             f => looksLikeTwoBit(f) || looksLikeHubTxt(f));
         if (hubDefined) {
             for (let f of this.uppy.getFiles()) {
                 let g = looksLikeTwoBit(f) ?
                     (f.meta.genome || hubCreate.sanitizeGenomeName(f.name)) : f.meta.genome;
                 if (g && !lockedGenomes.includes(g)) {
                     lockedGenomes.push(g);
                 }
             }
         }
         // The genome row is built one way for a locked genome and another for a
         // free one, so a 2bit or hub.txt joining or leaving an existing batch has
         // to rebuild the whole thing rather than leave the old row in place. The
         // signature also changes when the locked genome is renamed, which is what
         // keeps the read-only box from showing a stale name
         let asmSignature = lockedGenomes.join(", ");
         let staleDiv = document.getElementById("batch-selector-div");
         if (staleDiv) {
             if (staleDiv.dataset.asmGenome === asmSignature) {
                 refreshBatchHubNameInput(this.uppy);
                 return;
             }
             removeBatchSelectDiv();
         }
         let lockedGenome = lockedGenomes.length ? asmSignature : null;
 
         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:";
         // syntax here is rowStart / columnStart / rowEnd / columnEnd
         batchSelectText.style.gridArea = "1 / 1 / 1 / 2";
 
         let batchDbLabel = document.createElement("label");
         batchDbLabel.textContent = "Genome";
         batchDbLabel.style.gridArea = "2 / 1 / 2 / 1";
 
         let batchDbSelect = null;
         let batchDbGenomeSearchBar = null;
         let batchDbGenomeSearchButton = null;
         let batchDbSearchBarLabel = null;
 
         if (lockedGenome) {
             // The genome is decided by a 2bit or a hub.txt: show it as a locked
             // text field, no UCSC picker or search.
             let locked = document.createElement("input");
             locked.type = "text";
             locked.id = "batchAsmHubGenome";
             locked.value = lockedGenome;
             locked.disabled = true;
             locked.classList.add("uppy-u-reset", "uppy-c-textInput");
             locked.style.gridArea = "2 / 2 / 2 / 2";
             locked.style.margin = "2px";
             batchDbLabel.for = "batchAsmHubGenome";
 
             // say which file decided the genome, so the box is not just read-only
             // with no explanation
             let note = document.createElement("div");
             if (lockedGenomes.length > 1) {
                 note.textContent = "(genome per file is set by genomes.txt; this list shows all genomes in the hub)";
             } else if (this.uppy.getFiles().some(looksLikeTwoBit)) {
                 note.textContent = "(assembly hub - genome locked; shared by all files in this batch)";
             } else {
                 note.textContent = "(genome locked by hub.txt; shared by all files in this batch)";
             }
             note.style.gridArea = "2 / 3 / 2 / 5";
             note.style.margin = "auto 0";
             note.style.fontStyle = "italic";
 
             batchSelectDiv.appendChild(batchSelectText);
             batchSelectDiv.appendChild(batchDbLabel);
             batchSelectDiv.appendChild(locked);
             batchSelectDiv.appendChild(note);
         } else {
             // Track hub: the usual UCSC picker + autocomplete.
             batchDbSelect = document.createElement("select");
             this.createOptsForSelect(batchDbSelect, hubCreate.makeGenomeSelectOptions());
             batchDbSelect.id = "batchDbSelect";
             batchDbSelect.style.gridArea = "2 / 2 / 2 / 2";
             batchDbSelect.style.margin = "2px";
             batchDbLabel.for = "batchDbSelect";
 
             batchDbSearchBarLabel = document.createElement("label");
             batchDbSearchBarLabel.textContent = "or search for your genome:";
             batchDbSearchBarLabel.style.gridArea = "2 / 3 /2 / 3";
             batchDbSearchBarLabel.style.margin = "auto";
 
             batchDbGenomeSearchBar = document.createElement("input");
             batchDbGenomeSearchBar.classList.add("uppy-u-reset", "uppy-c-textInput");
             batchDbGenomeSearchBar.type = "text";
             batchDbGenomeSearchBar.id = "batchDbSearchBar";
             batchDbGenomeSearchBar.style.gridArea = "2 / 4 / 2 / 4";
             batchDbGenomeSearchButton = document.createElement("input");
             batchDbGenomeSearchButton.type = "button";
             batchDbGenomeSearchButton.value = "search";
             batchDbGenomeSearchButton.id = "batchDbSearchBarButton";
             batchDbGenomeSearchButton.style.gridArea = "2 / 5 / 2 / 5";
 
             batchDbSelect.addEventListener("change", (ev) => {
                 let files = this.uppy.getFiles();
                 let val = ev.target.value;
                 let label = ev.target.selectedOptions[0].label;
                 let hub = hubCreate.assemblyHubByGenome(val);
                 // Keep the hub name the user typed or that came from the folder
                 // they opened, only an untouched default gets replaced. A genome
                 // from one of their assembly hubs still moves the files, that hub
                 // is the only place the genome exists
                 let nameInput = document.getElementById("batchParentDir");
                 let currentRoot = (nameInput ? nameInput.value : "").trim();
                 let keepName = currentRoot && (userSetBatchHubName ||
                         currentRoot !== hubCreate.uiState.hubNameDefault);
                 let newRoot;
                 if (hub) {
                     newRoot = hub.fullPath;
                 } else if (keepName) {
                     newRoot = currentRoot;
                 } else {
                     newRoot = hubCreate.uiState.hubNameDefault;
                 }
                 for (let [key, file] of Object.entries(files)) {
                     // Keep the file's subdirectory under whatever root the
                     // batch genome change implies; only the root segment
                     // moves.
                     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;
                     }
                     let meta = {
                         genome: val,
                         genomeLabel: label,
                         hubType: hub ? "assemblyHub" : "trackHub",
                         parentDir: newParent,
                     };
                     this.uppy.setFileMeta(file.id, meta);
                 }
                 // show where the files actually went. Assigning the value fires no
                 // change event, so this does not count as the user naming the hub
                 if (nameInput) {
                     nameInput.value = newRoot;
                 }
             });
 
             batchSelectDiv.appendChild(batchSelectText);
             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});
             }
             // 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) {
             // nothing to attach to yet. Bail rather than fall through to the
             // autocomplete setup below, which would memoize an id belonging to a
             // detached element and leave the search box dead for the rest of the page
             return;
         }
         uppyFilesDiv.insertBefore(batchSelectDiv, uppyFilesDiv.firstChild);
         refreshBatchHubNameInput(this.uppy);
 
         // autocomplete only applies in the track-hub path
         if (batchDbSelect && batchDbGenomeSearchBar && batchDbGenomeSearchButton) {
             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) {
                     this.uppy.removeFile(file.id);
                     // Close the file card if it auto-opened for the first 2bit;
                     // otherwise it covers the error banner.
                     const dash = this.uppy.getPlugin("Dashboard");
                     if (dash) dash.toggleFileCard(false);
                     // Long duration so the user has time to read it; the
                     // StatusBar (setState.error) truncates to "Upload failed"
                     // and hides the message behind a "?" icon.
                     this.uppy.info(
                         `Only one 2bit file per hub is allowed. ` +
                         `"${existingTwoBits[0].name}" was added; ` +
                         `"${file.name}" was not. To create a separate ` +
                         `hub for "${file.name}", upload it on its own.`,
                         'error', 15000);
                     return;
                 }
             }
             // Default meta; folder drops preserve their subdirectory.
             let ftype = hubCreate.detectFileType(file.name);
             let dropPath = parentDirFromRelativePath(file);
             let defaultParentDir = dropPath || hubCreate.getDefaultHubName();
             let defaultMeta = {
                 "genome": hubCreate.defaultDb(),
                 "fileType": ftype,
                 "parentDir": defaultParentDir,
                 "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";
             }
             this.uppy.setFileMeta(file.id, defaultMeta);
 
             // When drilled into an assembly hub, inherit and lock its genome.
             let openDir = hubCreate.uiState.currentHubPath || hubCreate.uiState.currentHub;
             if (openDir && openDir === defaultMeta.parentDir) {
                 let existing = hubCreate.uiState.filesHash[defaultMeta.parentDir];
                 if (existing && existing.hubType === "assemblyHub") {
                     this.uppy.setFileMeta(file.id, {
                         genome: existing.genome,
                         genomeLabel: existing.genome,
                         hubType: "assemblyHub",
                         genomeLocked: true,
                     });
                 }
             }
 
             // If a 2bit is in the batch, every sibling file in the same parentDir
             // adopts its genome and gets hubType=assemblyHub. Also handle hub.txt:
             // parse it client-side and, if it declares an assembly hub, mirror
             // those values onto every file (hub.txt wins).
             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;
                     }
                     // the genome was the 2bit's assembly name, which means
                     // nothing without the 2bit. Clear it so the upload check
                     // makes the user pick a real genome
                     this.uppy.setFileMeta(f.id, {
                         hubType: "trackHub",
                         genomeLocked: false,
                         genome: "",
                         genomeLabel: "",
                     });
                 }
             }
             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 > 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();
             }
         });
         this.uppy.on("dashboard:file-edit-start", (file) => {
             autocompletes[`${file.name}DbInput`] = false;
         });
 
         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);
                 }
             }
             // Renaming the assembly on the 2bit's card leaves its siblings on the
             // old name, which reads as two genomes in one hub. Restamp them from
             // the 2bit first, the way adding a file does, and say so since the
             // user only edited the one card
             if (file && looksLikeTwoBit(file)) {
                 let asmGenome = file.meta.genome || hubCreate.sanitizeGenomeName(file.name);
                 let renamed = this.uppy.getFiles().filter(
                     f => f.id !== file.id && f.meta && f.meta.genome !== asmGenome);
                 if (asmGenome && renamed.length) {
                     let lead;
                     if (renamed.length === 1) {
                         lead = "The other file in this batch now uses";
                     } else {
                         lead = `The other ${renamed.length} files in this batch now use`;
                     }
                     uppy.info(`${lead} the genome "${asmGenome}", since every file ` +
                               `in the batch goes into this one assembly hub.`, "info", 5000);
                 }
                 propagateAssemblyHubMeta(this.uppy);
             }
             // a hub name or genome edited on a file card has to reach the batch
             // boxes too, or they keep showing what the batch used to say
             refreshBatchHubNameInput(this.uppy);
             refreshBatchSelects(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
                             // when it is a subdirectory like myHub/hg38
         isLoggedIn: "",
         maxQuota: 0,
         userQuota: 0,
         userFiles: {}, // same as uiData.userFiles on page load
-        filesHash: {}, // for each file, userFiles.fullPath is the key, and then the userFiles.fileList data as the value, with an extra key for the child fullPaths if the file is a directory
+        // Object.create(null) because a hub may be named 'constructor' or 'toString'
+        filesHash: Object.create(null), // for each file, userFiles.fullPath is the key, and then the userFiles.fileList data as the value, with an extra key for the child fullPaths if the file is a directory
     };
 
     let extensionMap = {
         "bigBed": [".bb", ".bigbed"],
         "bam": [".bam"],
         "vcf": [".vcf"],
         "vcfTabix": [".vcf.gz", "vcf.bgz"],
         "bigWig": [".bw", ".bigwig"],
         "hic": [".hic"],
         "cram": [".cram"],
         "bigBarChart": [".bigbarchart"],
         "bigGenePred": [".bgp", ".biggenepred"],
         "bigMaf": [".bigmaf"],
         "bigInteract": [".biginteract"],
         "bigPsl": [".bigpsl"],
         "bigChain": [".bigchain"],
         "bamIndex": [".bam.bai", ".bai"],
         "tabixIndex": [".vcf.gz.tbi", "vcf.bgz.tbi"],
         "hub.txt": ["hub.txt"],
         "2bit": [".2bit"],
         "text": [".txt", ".text"],
     };
 
     function getDefaultHubName() {
         // with a directory open, new files default into that directory, which for a
         // subdirectory is the whole path like myHub/hg38
         let openDir = uiState.currentHubPath || uiState.currentHub;
         return openDir.length > 0 ? openDir : uiState.hubNameDefault;
     }
 
     function hubRootFromPath(path) {
         // the hub is the first path segment: hub.txt and the hub's own row live there
         // even for a file down in a subdirectory of the hub. Matches
         // hubRootFromParentDir in hg/lib/userdata.c
         return path ? path.split("/")[0] : "";
     }
 
     function hubRootForCurrentDir() {
         // the hub of the directory the user has open
         return hubRootFromPath(uiState.currentHubPath || uiState.currentHub);
     }
 
     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) {
         // Return the dir row of the user's assembly hub whose genome matches,
         // or null. If genome is falsy, return the first assembly hub found.
         for (let fullPath in uiState.filesHash) {
             let fd = uiState.filesHash[fullPath];
             if (fd.fileType !== "dir" || fd.hubType !== "assemblyHub" || !fd.genome) continue;
             if (!genome || fd.genome === genome) return fd;
         }
         return null;
     }
 
     function firstAssemblyHub() { return assemblyHubByGenome(null); }
     function genomeIsAssemblyHub(genome) { return !!genome && !!assemblyHubByGenome(genome); }
 
     function parseRaSettings(text) {
         // Parse stanza-style .ra text. Blank lines separate stanzas; #-comments are skipped.
         let stanzas = [];
         let current = null;
         if (!text) return stanzas;
         for (let raw of text.split(/\r?\n/)) {
             let line = raw.replace(/^\s+/, "");
             if (line === "") {
                 if (current) { stanzas.push(current); current = null; }
                 continue;
             }
             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, hubName}.
         let ret = {genome: null, twoBitPath: null, isAssemblyHub: 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; }
             }
         }
         if (!ret.twoBitPath) {
             for (let s of stanzas) {
                 if (s.twoBitPath) {
                     ret.twoBitPath = s.twoBitPath;
                     ret.isAssemblyHub = true;
                     break;
                 }
             }
         }
         return ret;
     }
 
     function basename(p) {
         if (!p) return "";
         let i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
         return i === -1 ? p : p.substring(i + 1);
     }
 
     function findFileInBatch(files, refPath) {
         // Match a hub.txt/genomes.txt path against the batch, by relativePath or basename.
         let target = refPath.replace(/^\.\//, "");
         let targetBase = basename(target);
         let exactPathMatch = null;
         let baseMatch = null;
         for (let f of files) {
             let rel = (f.meta && f.meta.relativePath) ||
                       (f.data && f.data.webkitRelativePath) ||
                       f.relativePath || "";
             if (rel && (rel === target || rel.endsWith("/" + target))) {
                 exactPathMatch = f;
                 break;
             }
             if ((f.meta && f.meta.name === targetBase) || f.name === targetBase) {
                 if (!baseMatch) baseMatch = f;
             }
         }
         return exactPathMatch || baseMatch || null;
     }
 
     function parseTrackDbForDataFiles(text) {
         // Return all bigDataUrl-style references in a trackDb.txt.
         let refs = [];
         if (!text) return refs;
         for (let raw of text.split(/\r?\n/)) {
             let line = raw.replace(/^\s+/, "");
             for (let key of ["bigDataUrl", "bigDataIndex", "bamIndex",
                              "indexUrl", "searchTrix"]) {
                 if (line.startsWith(key + " ") || line.startsWith(key + "\t")) {
                     refs.push(line.substring(key.length).trim());
                     break;
                 }
             }
         }
         return refs;
     }
 
     let parseBatchSeq = 0;
     let latestCompletedParseSeq = 0;
     async function parseHubBatch(files) {
         // Walk the upload batch and build a hub descriptor:
         //   {
         //     isSplit:        bool,     // hub.txt uses genomesFile=
         //     isAssemblyHub:  bool,
         //     hubFile:        file,     // hub.txt file in batch (or null)
         //     genomesFile:    file,     // genomes.txt file in batch (split only)
         //     genomes: [
         //       { name, twoBitFile, trackDbFile, dataFiles: [file, ...] }
         //     ],
         //     fileGenome:     Map<fileId, genomeName>,   // hub.txt/genomes.txt absent
         //     errors:         [string, ...],
         //     parseSeq:       number,   // monotonic id of this parse
         //   }
         // Single-file layouts (useOneFile / no genomesFile) return isSplit=false.
         let mySeq = ++parseBatchSeq;
         let descriptor = {
             isSplit: false,
             isAssemblyHub: false,
             hubFile: null,
             hubMeta: null,
             genomesFile: null,
             genomes: [],
             fileGenome: new Map(),
             errors: [],       // upload-blocking: missing referenced files
             warnings: [],     // surfaced but don't block: orphans, parse hiccups
             parseSeq: mySeq,
         };
         function cacheAndReturn() {
             if (mySeq > latestCompletedParseSeq) {
                 latestCompletedParseSeq = mySeq;
                 lastHubBatchDescriptor = descriptor;
             }
             return descriptor;
         }
         let hubTxt = files.find(looksLikeHubTxt);
         if (!hubTxt) return cacheAndReturn();
         descriptor.hubFile = hubTxt;
 
         let hubText;
         try {
             hubText = await readFileAsText(hubTxt.data);
         } catch (e) {
             descriptor.errors.push("Could not read hub.txt: " + e);
             return cacheAndReturn();
         }
 
         let hubParsed = parseHubTxt(hubText);
         descriptor.hubMeta = hubParsed;
         descriptor.isAssemblyHub = hubParsed.isAssemblyHub;
         if (!hubParsed.genomesFile || hubParsed.useOneFile) {
             // Not a split hub - existing single-file flow handles it.
             return cacheAndReturn();
         }
 
         let genomesFile = findFileInBatch(files, hubParsed.genomesFile);
         if (!genomesFile) {
             descriptor.errors.push(
                 `hub.txt references genomesFile=${hubParsed.genomesFile}, but ` +
                 `that file is not in the upload batch. Add it and try again.`);
             return cacheAndReturn();
         }
         descriptor.genomesFile = genomesFile;
 
         let genomesText;
         try {
             genomesText = await readFileAsText(genomesFile.data);
         } catch (e) {
             descriptor.errors.push("Could not read genomes.txt: " + e);
             return cacheAndReturn();
         }
         descriptor.isSplit = true;
 
         let genomeStanzas = parseRaSettings(genomesText);
         for (let stanza of genomeStanzas) {
             if (!stanza.genome) continue;
             let entry = { name: stanza.genome, twoBitFile: null,
                           trackDbFile: null, dataFiles: [] };
             if (stanza.trackDb) {
                 entry.trackDbFile = findFileInBatch(files, stanza.trackDb);
                 if (!entry.trackDbFile) {
                     descriptor.errors.push(
                         `genomes.txt references trackDb=${stanza.trackDb} for ` +
                         `genome ${stanza.genome}, but that file is not in the ` +
                         `upload batch.`);
                 }
             }
             if (stanza.twoBitPath) {
                 entry.twoBitFile = findFileInBatch(files, stanza.twoBitPath);
                 if (!entry.twoBitFile) {
                     descriptor.errors.push(
                         `genomes.txt references twoBitPath=${stanza.twoBitPath} ` +
                         `for genome ${stanza.genome}, but that file is not in ` +
                         `the upload batch.`);
                 }
                 descriptor.isAssemblyHub = true;
             }
             descriptor.genomes.push(entry);
         }
 
         for (let g of descriptor.genomes) {
             if (!g.trackDbFile) continue;
             let trackDbText;
             try {
                 trackDbText = await readFileAsText(g.trackDbFile.data);
             } catch (e) {
                 descriptor.errors.push(
                     `Could not read trackDb for ${g.name}: ${e}`);
                 continue;
             }
             let refs = parseTrackDbForDataFiles(trackDbText);
             for (let ref of refs) {
                 let dataFile = findFileInBatch(files, ref);
                 if (dataFile) {
                     g.dataFiles.push(dataFile);
                     descriptor.fileGenome.set(dataFile.id, g.name);
                 }
                 // bigDataUrl targets are allowed to be missing - data files
                 // can arrive in later batches.
             }
             descriptor.fileGenome.set(g.trackDbFile.id, g.name);
             if (g.twoBitFile) {
                 descriptor.fileGenome.set(g.twoBitFile.id, g.name);
             }
         }
 
         // Mark hub.txt and genomes.txt as hub-level (null genome).
         descriptor.fileGenome.set(hubTxt.id, null);
         descriptor.fileGenome.set(genomesFile.id, null);
 
         // Flag orphans as warnings: the upload still works (the file lands
         // on disk and in hubSpace) but trackDb won't reference it until the
         // user adds a track stanza.
         for (let f of files) {
             if (descriptor.fileGenome.has(f.id)) continue;
             descriptor.warnings.push(
                 `File ${f.name} is in the batch but is not referenced by any ` +
                 `trackDb in the hub definition. It will be uploaded as an ` +
                 `orphan; add a track stanza if you want it to display.`);
         }
 
         return cacheAndReturn();
     }
 
     let lastHubBatchDescriptor = null;
     function getLastHubBatchDescriptor() { return lastHubBatchDescriptor; }
     function clearLastHubBatchDescriptor() { lastHubBatchDescriptor = null; }
 
     function readFileAsText(fileObj) {
         // Return a Promise resolving to the file contents as text.
         return new Promise((resolve, reject) => {
             let reader = new FileReader();
             reader.onload = () => resolve(reader.result);
             reader.onerror = () => reject(reader.error);
             reader.readAsText(fileObj);
         });
     }
 
     function detectFileType(fileName) {
         let fileLower = fileName.toLowerCase();
         for (let fileType in extensionMap) {
             for (let ext of extensionMap[fileType]) {
                 if (fileLower.endsWith(ext)) {
                     return fileType;
                 }
             }
         }
         //we could alert here but instead just explicitly set the value to null
         //and let the backend reject it instead, forcing the user to rename their
         //file
         //alert(`file extension for ${fileName} not found, please explicitly select it`);
         return null;
     }
 
     function defaultDb() {
         return cartDb.split(" ").slice(-1)[0];
     }
 
     let defaultGenomeChoices = {
         "Human hg38": {value: "hg38", label: "Human hg38"},
         "Human T2T": {value: "hs1", label: "Human T2T"},
         "Human hg19": {value: "hg19", label: "Human hg19"},
         "Mouse mm39": {value: "mm39", label: "Mouse mm39"},
         "Mouse mm10": {value: "mm10", label: "Mouse mm10"}
     };
 
     function makeGenomeSelectOptions(value, label) {
         // Returns an array of options for genomes, if value and label exist, add that
         // as an additional option
         let ret = [];
         let cartChoice = {};
         cartChoice.id = cartDb;
         cartChoice.label = cartDb;
         cartChoice.value = cartDb.split(" ").slice(-1)[0];
         if (cartChoice.value.startsWith("hub_")) {
             cartChoice.label = cartDb.split(" ").slice(0,-1).join(" "); // take off the actual db value
         }
         cartChoice.selected = value && label ? false: true;
         defaultGenomeChoices[cartChoice.label] = cartChoice;
 
         // Add an explicitly chosen genome (e.g. from the search box) before
         // building the list so it is selectable on this render, not the next.
         // Skip assembly-hub genomes, which the loop below adds with a suffix.
         if (value && label && !(label in defaultGenomeChoices) &&
             !genomeIsAssemblyHub(value)) {
             defaultGenomeChoices[label] = {value: value, label: label};
         }
         ret = Object.values(defaultGenomeChoices);
 
         // Include the user's uploaded assembly hubs as options. One entry per
         // assembly hub (dedupe by genome name), taken from the dir row in
         // filesHash. This lets users picking a dropdown genome target a hub
         // they already created.
         let seenAsmHub = {};
         for (let fullPath in uiState.filesHash) {
             let fd = uiState.filesHash[fullPath];
             if (fd.fileType === "dir" && fd.hubType === "assemblyHub" &&
                 fd.genome && !seenAsmHub[fd.genome]) {
                 seenAsmHub[fd.genome] = true;
                 ret.push({
                     value: fd.genome,
                     label: `${fd.genome} (your assembly hub)`,
                 });
             }
         }
 
         return ret;
     }
 
     function makeTypeSelectOptions() {
         let ret = [];
         let autoChoice = {};
         autoChoice.label = "Auto-detect from extension";
         autoChoice.value = "Auto-detect from extension";
         autoChoice.selected = true;
         ret.push(autoChoice);
         let choices = ["bigBed", "bam", "vcf", "vcf (bgzip or gzip compressed)", "bigWig", "hic", "cram", "bigBarChart", "bigGenePred", "bigMaf", "bigInteract", "bigPsl", "bigChain"];
         choices.forEach( (e) =>  {
             let choice = {};
             choice.id = e;
             choice.label = e;
             choice.value = e;
             ret.push(choice);
         });
         return ret;
     }
 
     function findHubGenome(hubName) {
         // Walk the hub subtree for the first non-empty genome. Split-hub
         // root dirs and hub-level files carry "" and need this fallback.
         let dir = uiState.filesHash[hubName];
         if (!dir) return null;
         if (dir.genome) return dir.genome;
         if (!dir.children) return null;
         for (let c of dir.children) {
             if (c.genome) return c.genome;
             if (c.fileType === "dir") {
                 let nested = findHubGenome(c.fullPath);
                 if (nested) return nested;
             }
         }
         return null;
     }
 
     function isAssemblyHub(hubName) {
         // Hub-root dir's hubType can be "trackHub" if a hub-level file
         // uploaded first; walk the subtree for any assemblyHub or 2bit child.
         let dir = uiState.filesHash[hubName];
         if (!dir) return false;
         if (dir.hubType === "assemblyHub") return true;
         if (!dir.children) return false;
         for (let c of dir.children) {
             if (c.hubType === "assemblyHub" || c.fileType === "2bit") return true;
             if (c.fileType === "dir" && isAssemblyHub(c.fullPath)) return true;
         }
         return false;
     }
 
     function viewInGenomeBrowser(fname, ftype, genome, hubName, hubType) {
         // redirect to hgTracks with this track open in the hub
         if (typeof uiState.userUrl !== "undefined" && uiState.userUrl.length > 0) {
             if (ftype in extensionMap) {
                 // TODO: tusd should return this location in it's response after
                 // uploading a file and then we can look it up somehow, the cgi can
                 // write the links directly into the html directly for prev uploaded files maybe?
                 let hubUrl = uiState.userUrl + cgiEncode(hubTxtPathForHub(hubName));
                 // Assembly hubs use the user-defined genome name, which isn't a
                 // UCSC db - hgTracks needs 'genome=' (resolves via the hub)
                 // rather than 'db=' (looks up a native assembly).
                 let dbParam = hubType === "assemblyHub" ? "genome" : "db";
                 let url = "../cgi-bin/hgTracks?hgsid=" + getHgsid() + "&" + dbParam + "=" + genome + "&hubUrl=" + encodeURIComponent(hubUrl) + "&" + trackHubFixName(fname) + "=pack";
                 window.location.assign(url);
                 return false;
             }
         }
     }
 
     function trackHubFixName(trackName) {
         // replace everything but alphanumeric and underscore with underscore
         return encodeURIComponent(trackName.replaceAll(fileNameFixRegex, "_"));
     }
 
     function viewHubInGenomeBrowser(hubName) {
         // connect the whole hub in hgTracks, without pack'ing any specific track
         if (typeof uiState.userUrl === "undefined" || uiState.userUrl.length === 0) {
             return;
         }
         let dirRow = uiState.filesHash[hubName];
         if (!dirRow) return;
         let hubUrl = uiState.userUrl + cgiEncode(hubTxtPathForHub(hubName));
         let dbParam = isAssemblyHub(hubName) ? "genome" : "db";
         let genome = dirRow.genome || findHubGenome(hubName) || "";
         let url = "../cgi-bin/hgTracks?hgsid=" + getHgsid() + "&" + dbParam + "=" + genome + "&hubUrl=" + encodeURIComponent(hubUrl);
         window.location.assign(url);
     }
 
     function hubHasHubTxt(hubName) {
         // true if the hub directory has a hub.txt file recorded in hubSpace
         let dir = uiState.filesHash[hubName];
         if (dir && dir.children) {
             for (let child of dir.children) {
                 if (child.fileType === "hub.txt") return true;
             }
         }
         return false;
     }
 
     function hubShareLink(hubName) {
         // build an absolute, shareable hgTracks link that connects this hub and
         // nothing else. No hgsid so the recipient uses their own session.
         if (typeof uiState.userUrl === "undefined" || uiState.userUrl.length === 0) {
             return null;
         }
         let dirRow = uiState.filesHash[hubName];
         if (!dirRow) return null;
         let hubUrl = uiState.userUrl + cgiEncode(hubTxtPathForHub(hubName));
         let dbParam = isAssemblyHub(hubName) ? "genome" : "db";
         let genome = dirRow.genome || findHubGenome(hubName) || "";
         return window.location.origin + "/cgi-bin/hgTracks?" + dbParam + "=" + genome +
             "&hubUrl=" + encodeURIComponent(hubUrl);
     }
 
     function copyLinkIconSvg(title, dataUrl) {
         // clipboard icon that the table click handler copies from its data-url.
         // The title is both an attribute and a <title> child so the tooltip works
         // across browsers (Firefox ignores title on an svg element).
         let safeUrl = dataUrl.replaceAll("&", "&amp;").replaceAll('"', "&quot;");
         let safeTitle = title.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll('"', "&quot;");
         return '<svg class="copyLinkIcon" title="' + safeTitle + '" data-url="' + safeUrl + '" style="margin-left: 6px; cursor: pointer; vertical-align:baseline; width:0.8em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><title>' + safeTitle + '</title><path d="M502.6 70.63l-61.25-61.25C435.4 3.371 427.2 0 418.7 0H255.1c-35.35 0-64 28.66-64 64l.0195 256C192 355.4 220.7 384 256 384h192c35.2 0 64-28.8 64-64V93.25C512 84.77 508.6 76.63 502.6 70.63zM464 320c0 8.836-7.164 16-16 16H255.1c-8.838 0-16-7.164-16-16L239.1 64.13c0-8.836 7.164-16 16-16h128L384 96c0 17.67 14.33 32 32 32h47.1V320zM272 448c0 8.836-7.164 16-16 16H63.1c-8.838 0-16-7.164-16-16L47.98 192.1c0-8.836 7.164-16 16-16H160V128H63.99c-35.35 0-64 28.65-64 64l.0098 256C.002 483.3 28.66 512 64 512h192c35.2 0 64-28.8 64-64v-32h-47.1L272 448z"/></svg>';
     }
 
     function copyHubLinkFromBanner(ev) {
         // copy the shareable hub link stashed on the button's data-url
         ev.stopPropagation();
         let btn = ev.currentTarget;
         let url = btn.getAttribute("data-url");
         if (!url) return;
         navigator.clipboard.writeText(url).then(function() {
             let orig = btn.textContent;
             btn.textContent = "Copied";
             setTimeout(function() { btn.textContent = orig; }, 1500);
         }, function() {
             alert("Failed to copy link: " + url);
         });
     }
 
     function showHubBanner(hubName) {
         let banner = document.getElementById("hubBanner");
         let nameSpan = document.getElementById("hubBannerName");
         if (!banner || !nameSpan) return;
         nameSpan.textContent = hubName;
         // stash a shareable connect link on the copy button, or hide it if unavailable
         let copyBtn = document.getElementById("hubBannerCopyBtn");
         if (copyBtn) {
             let link = hubShareLink(hubName);
             if (link) {
                 copyBtn.setAttribute("data-url", link);
                 copyBtn.style.display = "";
             } else {
                 copyBtn.style.display = "none";
             }
         }
         banner.style.display = "";
     }
 
     function hideHubBanner() {
         let banner = document.getElementById("hubBanner");
         if (banner) banner.style.display = "none";
     }
 
     // helper object so we don't need to use an AbortController to update
     // the data this function is using
     let selectedData = {};
     // track which items the user directly selected (vs children of selected directories)
     let directlySelected = {};
     function viewAllInGenomeBrowser(ev) {
         // redirect to hgTracks with these tracks/hubs open
         let data = selectedData;
         if (typeof uiState.userUrl !== "undefined" && uiState.userUrl.length > 0) {
             let url = "../cgi-bin/hgTracks?hgsid=" + getHgsid();
             let genome; // may be multiple genomes in list, just redirect to the first one
                         // TODO: this should probably raise an alert to click through
             let hubsAdded = {};
             _.forEach(data, (d) => {
                 let hubRoot = hubRootFromPath(d.fullPath);
                 if (!genome) {
                     // Hub-level rows carry empty db; fall back via the subtree.
                     genome = d.genome;
                     if (!genome && hubRoot) {
                         genome = findHubGenome(hubRoot);
                     }
                     if (genome) {
                         let isAsm = (d.hubType === "assemblyHub") ||
                                     (hubRoot && isAssemblyHub(hubRoot));
                         let dbParam = isAsm ? "genome" : "db";
                         url += "&" + dbParam + "=" + genome;
                     }
                 }
                 if (d.fileType === "hub.txt") {
                     url += "&hubUrl=" + encodeURIComponent(uiState.userUrl + cgiEncode(d.fullPath));
                 }
                 else if (d.fileType in extensionMap) {
                     // TODO: tusd should return this location in it's response after
                     // uploading a file and then we can look it up somehow, the cgi can
                     // write the links directly into the html directly for prev uploaded files maybe?
                     if (!(hubRoot in hubsAdded)) {
                         // NOTE: hubUrls get added regardless of whether they are on this assembly
                         // or not, because multiple genomes may have been requested. If this user
                         // switches to another genome we want this hub to be connected already
                         // Resolve the actual hub.txt filename - user may have
                         // uploaded "<prefix>.hub.txt" rather than literal hub.txt.
                         url += "&hubUrl=" + encodeURIComponent(uiState.userUrl + cgiEncode(hubTxtPathForHub(hubRoot)));
                     }
                     hubsAdded[hubRoot] = true;
                     if (d.genome == genome) {
                         // turn the track on if its for this db
                         url += "&" + trackHubFixName(d.fileName) + "=pack";
                     }
                 }
             });
             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;
             // hubType lives on the hub's own row, which for a 2bit in a
             // subdirectory is not the directory holding it
             let hubRoot = hubRootFromPath(d.fullPath);
             let hub = uiState.filesHash[hubRoot];
             if (!hub || hub.hubType !== "assemblyHub") continue;
             if (!selectedHubDirs.has(hubRoot)) 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 are part of ` +
                   `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,
                 genome: d.genome,
                 fullPath: d.fullPath,
             });
         });
         cart.send(cartData, deleteFileSuccess);
         cart.flush();
     }
 
     function updateSelectedFileDiv(data, isFolderSelect = false) {
         // update the div that shows how many files are selected, both below the
         // table and in a banner above it
         let numSelected = data !== null ? data.length : 0;
         // the above-table banner is only used in the top level view, not inside a hub
         let atTopLevel = !uiState.currentHub;
         let infoDiv = document.getElementById("selectedFileInfo");
         let span = document.getElementById("numberSelectedFiles");
         let spanParentDiv = span.parentElement;
         let banner = document.getElementById("selectedFileBanner");
         let bannerSpan = document.getElementById("numberSelectedFilesBanner");
         if (numSelected > 0) {
             let label;
             if (isFolderSelect || span.textContent.endsWith("hub") || span.textContent.endsWith("hubs")) {
                 label = `${numSelected} ${numSelected > 1 ? "hubs" : "hub"}`;
             } else {
                 label = `${numSelected} ${numSelected > 1 ? "files" : "file"}`;
             }
             span.textContent = label;
             bannerSpan.textContent = label;
             // (re) set up the handlers for the selected file info div:
             let viewBtn = document.getElementById("viewSelectedFiles");
             viewBtn.addEventListener("click", viewAllInGenomeBrowser);
             viewBtn.textContent = "View selected";
             let deleteBtn = document.getElementById("deleteSelectedFiles");
             deleteBtn.style.display = "inline-block";
             deleteBtn.addEventListener("click", deleteFileList);
             deleteBtn.textContent = "Delete selected";
             // mirror the controls in the banner above the table
             let bannerViewBtn = document.getElementById("viewSelectedFilesBanner");
             bannerViewBtn.addEventListener("click", viewAllInGenomeBrowser);
             bannerViewBtn.textContent = "View selected";
             bannerViewBtn.style.display = "inline-block";
             let bannerDeleteBtn = document.getElementById("deleteSelectedFilesBanner");
             bannerDeleteBtn.addEventListener("click", deleteFileList);
             bannerDeleteBtn.textContent = "Delete selected";
             bannerDeleteBtn.style.display = "inline-block";
             // when exactly one hub is selected, offer a shareable connect link
             let copyBtn = document.getElementById("copyHubLinkBanner");
             let singleHub = (data.length === 1 && data[0].fileType === "dir" &&
                 !data[0].parentDir && hubHasHubTxt(data[0].fullPath)) ? data[0].fullPath : null;
             let singleHubLink = singleHub ? hubShareLink(singleHub) : null;
             if (singleHubLink) {
                 copyBtn.textContent = "Share hub";
                 copyBtn.setAttribute("data-url", singleHubLink);
                 copyBtn.addEventListener("click", copyHubLinkFromBanner);
                 copyBtn.style.display = "inline-block";
             } else {
                 copyBtn.style.display = "none";
             }
         } else {
             span.textContent = "";
             // banner stays present at the top level, so show a zero count and no buttons
             bannerSpan.textContent = "0 hub";
             document.getElementById("viewSelectedFilesBanner").style.display = "none";
             document.getElementById("deleteSelectedFilesBanner").style.display = "none";
             document.getElementById("copyHubLinkBanner").style.display = "none";
         }
 
         // set the visibility of the placeholder text and info text
         spanParentDiv.style.display = numSelected === 0 ? "none": "block";
         let placeholder = document.getElementById("placeHolderInfo");
         placeholder.style.display = numSelected === 0 ? "block" : "none";
         // the share banner is always shown at the top level, hidden inside a hub
         banner.style.display = atTopLevel ? "" : "none";
     }
 
     function handleCheckboxSelect(evtype, table, selectedRow) {
         // depending on the state of the checkbox, we will be adding information
         // to the div, or removing information. We also potentially checked/unchecked
         // all of the checkboxes if the selectAll box was clicked.
 
         // The data variable will hold all the information we want to keep visible in the info div
         let data = [];
         // The selectedData global holds the actual information needed for the view/delete buttons
         // to work, so data plus any child rows
         selectedData = {};
         // Track only the rows the user directly selected (not children)
         directlySelected = {};
 
         // get all of the currently selected rows (may be more than just the one that
         // was most recently clicked)
         table.rows({selected: true}).data().each(function(row, ix) {
             data.push(row);
             selectedData[row.fullPath] = row;
             directlySelected[row.fullPath] = row;
             // add any newly checked rows children to the selectedData structure for the view/delete
             if (row.children) {
                 row.children.forEach(function(child) {
                     selectedData[child.fullPath] = child;
                 });
             }
         });
         updateSelectedFileDiv(data, selectedRow.data().fileType === "dir");
     }
 
     function createOneCrumb(table, dirName, dirFullPath, doAddEvent) {
         // make a new span that can be clicked to nav through the table
         let newSpan = document.createElement("span");
         newSpan.id = dirName;
         newSpan.textContent = decodeURIComponent(dirName);
         newSpan.classList.add("breadcrumb");
         if (doAddEvent) {
             newSpan.addEventListener("click", function(e) {
                 dataTableShowDir(table, dirName, dirFullPath);
                 // the whole row, so the back button this builds knows the parentDir
                 dataTableCustomOrder(table, uiState.filesHash[dirFullPath] || {"fullPath": dirFullPath});
                 table.draw();
             });
         } else {
             // can't click the final crumb so don't underline it
             newSpan.style.textDecoration = "unset";
         }
         return newSpan;
     }
 
     function dataTableEmptyBreadcrumb(table) {
         let currBreadcrumb = document.getElementById("breadcrumb");
         currBreadcrumb.replaceChildren(currBreadcrumb.firstChild);
     }
 
     function dataTableCreateBreadcrumb(table, dirName, dirFullPath) {
         // Re-create the breadcrumb nav to move back through directories
         let currBreadcrumb = document.getElementById("breadcrumb");
         // empty the node but leave the first "My Data" span
         if (currBreadcrumb.children.length > 1) {
             currBreadcrumb.replaceChildren(currBreadcrumb.firstChild);
         }
         let components = dirFullPath.split("/");
         let numComponents = components.length;
         components.forEach(function(dirName, dirNameIx) {
             if (!dirName) {
                 return;
             }
             let doAddEvent = dirNameIx !== (numComponents - 1);
             let path = components.slice(0, dirNameIx+1);
             componentFullPath = path.join('/');
             let newSpan = createOneCrumb(table, dirName, componentFullPath, doAddEvent);
             currBreadcrumb.appendChild(document.createTextNode(" > "));
             currBreadcrumb.appendChild(newSpan);
         });
     }
 
     // search related functions:
     function clearSearch(table) {
         // clear any fixed searches so we can apply a new one
         let currSearches = table.search.fixed().toArray();
         currSearches.forEach((name) => table.search.fixed(name, null));
     }
 
     function dataTableShowTopLevel(table) {
         // show all the "root" files, which are files (probably mostly directories)
         // with no parentDir
         clearSearch(table);
         // deselect any selected rows like Finder et al when moving into/upto a directory
         table.rows({selected: true}).deselect();
         table.search.fixed("showRoot", function(searchStr, rowData, rowIx) {
             return !rowData.parentDir;
         });
         uiState.currentHub = "";
         uiState.currentHubPath = "";
         hideHubBanner();
         updateSelectedFileDiv(null);
     }
 
     function dataTableShowDir(table, dirName, dirFullPath) {
         // show the directory and all immediate children of the directory
         clearSearch(table);
         // deselect any selected rows like Finder et al when moving into/upto a directory
         table.rows({selected: true}).deselect();
         // Callers must call table.draw() after this to render the new filter.
         table.search.fixed("oneHub", function(searchStr, rowData, rowIx) {
             // calculate the fullPath of this rows parentDir in case the dirName passed
             // to this function has the same name as a parentDir further up in the
             // listing. For example, consider a test/test/tmp.txt layout, where "test"
             // is the parentDir of tmp.txt and the test subdirectory
             let parentDirFull = rowData.fullPath.split("/").slice(0,-1).join("/");
             if (rowData.parentDir === dirName && parentDirFull === dirFullPath) {
                 return true;
             } else if (rowData.fullPath === dirFullPath) {
                 // also return the directory itself
                 return true;
             } else {
                 return false;
             }
         });
         uiState.currentHub = dirName;
         uiState.currentHubPath = dirFullPath;
         dataTableCreateBreadcrumb(table, dirName, dirFullPath);
         showHubBanner(hubRootForCurrentDir());
         updateSelectedFileDiv(null);
     }
 
     // when we move into a new directory, we remove the row from the table
     // and add it's html into the header, keep the row object around so
     // we can add it back in later
     let oldRowData = null;
     function dataTableCustomOrder(table, dirData) {
         // figure out the order the rows of the table should be in
         // if dirData is null, sort on  uploadTime first
         // if dirData exists, that is the first row, followed by everything else
         // in uploadTime order
         if (!dirData) {
             // make sure the old row can show up again in the table
             let thead = document.querySelector(".dt-scroll-headInner > table:nth-child(1) > thead:nth-child(1)");
             if (thead.childNodes.length > 1) {
                 let old = thead.removeChild(thead.lastChild);
                 if (oldRowData) {
                     table.row.add(oldRowData);
                     oldRowData = null;
                 }
             }
             table.order([{name: "uploadTime", dir: "desc"}]);
         } else {
             // move the dirName row into the header, then the other files can
             // sort normally
             if (oldRowData) {
                 // restore the previous row, which will be not displayed by the search anyways:
                 table.row.add(oldRowData);
                 oldRowData = null;
             }
             // A row only has a node while it is on the page being displayed, and
             // deferRender means the rows of other pages have none at all. Order by
             // fullPath so this directory sorts first, its path being a prefix of every
             // row the filter leaves visible, and draw to return to the first page.
             // Without this a directory holding more than one page of files sorts onto
             // a later page by uploadTime, and has no node to move into the header
             table.order([{name: "fullPath", dir: "asc"}]).draw();
             let row = table.row((idx,data) => data.fullPath === dirData.fullPath);
             let rowNode = row.node();
             if (!rowNode) {
                 // no row for this directory, so take out whatever directory the
                 // header is still showing rather than leave it naming another place
                 let staleHead = document.querySelector(".dt-scroll-headInner > table:nth-child(1) > thead:nth-child(1)");
                 if (staleHead.childNodes.length > 1) {
                     staleHead.removeChild(staleHead.lastChild);
                 }
                 table.order([{name: "uploadTime", dir: "desc"}]);
                 return;
             }
             oldRowData = row.data();
             // put the data in the header:
             let rowClone = rowNode.cloneNode(true);
             // match the background color of the normal rows:
             rowClone.style.backgroundColor = "#fff9d2";
             let thead = document.querySelector(".dt-scroll-headInner > table:nth-child(1) > thead:nth-child(1)");
             // remove the checkbox because it doesn't do anything, and replace it
             // with a back arrow 'button'
             let btn = document.createElement("button");
             btn.id = "backButton";
             $(btn).button({icon: "ui-icon-triangle-1-w"});
             btn.addEventListener("click", (e) => {
                 let parentDir = dirData.parentDir;
                 // Walk one level up by stripping the leaf segment.
                 let pathParts = dirData.fullPath.split("/");
                 let parentDirPath = pathParts.slice(0, -1).join("/");
                 if (parentDirPath.length) {
                     // Mirror the click-down path: filter, then move header row.
                     dataTableShowDir(table, parentDir, parentDirPath);
                     // the whole row, so going back again knows this directory's parent
                     dataTableCustomOrder(table, uiState.filesHash[parentDirPath] || {fullPath: parentDirPath});
                 } else {
                     dataTableShowTopLevel(table);
                     dataTableCustomOrder(table);
                     dataTableEmptyBreadcrumb(table);
                 }
                 table.draw();
             });
             let tdBtn = document.createElement("td");
             tdBtn.appendChild(btn);
             rowClone.replaceChild(tdBtn, rowClone.childNodes[0]);
             if (thead.childNodes.length === 1) {
                 thead.appendChild(rowClone);
             } else {
                 thead.replaceChild(rowClone, thead.lastChild);
             }
             // remove the row
             row.remove();
             // now do a regular order
             table.order([{name: "uploadTime", dir: "desc"}]);
         }
     }
 
     function parseFileListIntoHash(fileList) {
         // Hash the uiState fileList by the fullPath, and also store the children
         // for each directory
         // first go through and copy all of the data and make the empty
         // children array for each directory
         fileList.forEach(function(fileData) {
             uiState.filesHash[fileData.fullPath] = fileData;
             if (fileData.fileType === "dir") {
                 uiState.filesHash[fileData.fullPath].children = [];
             }
         });
         // use a second pass to go through and set the children
         // since we may not have encountered them yet in the above loop
         fileList.forEach(function(fileData) {
             if (fileData.fileType !== "dir" || fileData.parentDir !== "") {
                 // compute the key from the fullPath:
                 let parts = fileData.fullPath.split("/");
                 let keyName = parts.slice(0,-1).join("/");
                 if (keyName in uiState.filesHash) {
                     uiState.filesHash[keyName].children.push(fileData);
                 }
             }
         });
     }
 
     function getChildRows(dirFullPath, childRowArray) {
         // Recursively return all of the child rows for a given path
         let childRows = uiState.filesHash[dirFullPath].children;
         childRows.forEach(function(rowData) {
             if (rowData.fileType !== "dir") {
                 childRowArray.push(rowData);
             } else {
                 childRowArray.concat(getChildRows(rowData.fullPath, childRowArray));
             }
         });
     }
 
     function dataTablePrintSize(data, type, row, meta) {
         if (row.fileType !== "dir") {
             return prettyFileSize(data);
         } else {
             let childRows = [];
             getChildRows(row.fullPath, childRows);
             let sum = childRows.reduce( (accumulator, currentValue) => {
                 return accumulator + currentValue.fileSize;
             }, 0);
             return prettyFileSize(sum);
         }
     }
 
     function dataTablePrintGenome(data, type, row, meta) {
         if (data.startsWith("hub_"))
             return data.split("_").slice(2).join("_");
         return data;
     }
 
     function dataTablePrintAction(rowData) {
         /* Return a node for rendering the actions column */
         if (rowData.fileType === "dir") {
             let folderIcon = document.createElement("i");
             folderIcon.style.display = "inline-block";
             folderIcon.style.backgroundImage = "url(\"../images/folderC.png\")";
             folderIcon.style.backgroundPosition = "left center";
             folderIcon.style.backgroundRepeat = "no-repeat";
             folderIcon.style.width = "24px";
             folderIcon.style.height = "24px";
             folderIcon.classList.add("folderIcon");
             folderIcon.addEventListener("click", function(e) {
                 e.stopPropagation();
                 console.log("folder click");
                 let table = $("#filesTable").DataTable();
                 let trow = $(e.target).closest("tr");
                 let row = table.row(trow);
                 dataTableShowDir(table, rowData.fileName, rowData.fullPath);
                 dataTableCustomOrder(table, rowData);
                 table.draw();
             });
             return folderIcon;
         } else {
             // only offer the button if this is a track file
             if (rowData.fileType !== "hub.txt" && rowData.fileType !== "text" && rowData.fileType !== "tabixIndex" && rowData.fileType !== "bamIndex" && rowData.fileType !== "2bit" && rowData.fileType in extensionMap) {
                 let container = document.createElement("div");
                 let viewBtn = document.createElement("button");
                 viewBtn.textContent = "View in Genome Browser";
                 viewBtn.style.whiteSpace = "nowrap";
                 viewBtn.type = 'button';
                 viewBtn.addEventListener("click", function(e) {
                     e.stopPropagation();
                     viewInGenomeBrowser(rowData.fileName, rowData.fileType, rowData.genome, hubRootFromPath(rowData.fullPath), rowData.hubType);
                 });
                 container.appendChild(viewBtn);
                 return container;
             } else {
                 return null;
             }
         }
     }
 
     function deleteFileFromTable(pathList) {
         // req is an object with properties of an uploaded file, make a new row
         // for it in the filesTable
         let table = $("#filesTable").DataTable();
         let rows = table.rows((idx, data) => pathList.includes(data.fullPath));
         rows.remove().draw();
         let toKeep = (elem) => !pathList.includes(elem.fullPath);
         pathList.forEach((f) => {
             updateQuota(-uiState.filesHash[f].fileSize);
         });
         uiState.fileList = uiState.fileList.filter(toKeep);
         // Rebuild filesHash from remaining fileList to remove stale entries
-        uiState.filesHash = {};
+        uiState.filesHash = Object.create(null);
         parseFileListIntoHash(uiState.fileList);
         // If the currently viewed hub directory was deleted (its data is in oldRowData
         // because dataTableCustomOrder moved it to the header), clean up that stale state
         if (oldRowData && pathList.includes(oldRowData.fullPath)) {
             let thead = document.querySelector(
                 ".dt-scroll-headInner > table:nth-child(1) > thead:nth-child(1)");
             if (thead && thead.childNodes.length > 1) {
                 thead.removeChild(thead.lastChild);
             }
             oldRowData = null;
             dataTableShowTopLevel(table);
             dataTableEmptyBreadcrumb(table);
             table.order([{name: "uploadTime", dir: "desc"}]);
             table.draw();
         }
         history.replaceState(uiState, "", document.location.href);
     }
 
     function addFileToHub(rowData) {
         // a file has been uploaded and a hub has been created, present a modal
         // to choose which hub to associate this track to
         // backend wise: move the file into the hub directory
         //               update the hubSpace row with the hub name
         // frontend wise: move the file row into a 'child' of the hub row
         console.log(`sending addToHub req for ${rowData.fileName} to `);
         cart.setCgiAndUrl(fileListEndpoint);
         cart.send({addToHub: {hubName: "", dataFile: ""}});
         cart.flush();
     }
 
     function updateQuota(newFileSize) {
         // Change the quota displayed to the user, pass in newFileSize as a negative number
         // when deleting files
         let container = document.getElementById("quotaDiv");
         uiState.userQuota += newFileSize;
         container.textContent = `Using ${prettyFileSize(uiState.userQuota)} of ${prettyFileSize(uiState.maxQuota)}`;
     }
 
     // Response bodies from tus, keyed by upload URL. Uppy's tus plugin aborts the
     // request before it emits upload-success, and aborting an XMLHttpRequest clears
     // its status and its responseText, so the body has to be read while the request
     // is still live
     let tusResponseBodies = {};
 
     function rememberTusResponseBody(req, res) {
         // tus onAfterResponse hook, called for every request an upload makes. Only the
         // PATCH that finishes the upload carries the file list from the pre-finish hook
         if (req.getMethod() !== "PATCH") {
             return;
         }
         let body = res.getBody();
         if (body) {
             tusResponseBodies[req.getURL()] = body;
         }
     }
 
     function uploadedHubFromResponse(response) {
         // Return the hubSpace rows the pre-finish hook reported for this upload, or
         // null. tusd forwards the hook's response body on the request that completes
         // the upload, which rememberTusResponseBody saved under this upload's URL
         let url = response ? response.uploadURL : null;
         if (!url) {
             return null;
         }
         let text = tusResponseBodies[url];
         delete tusResponseBodies[url];
         if (!text) {
             return null;
         }
         try {
             let parsed = JSON.parse(text);
             return parsed.fileList && parsed.fileList.length > 0 ? parsed.fileList : null;
         } catch (e) {
             console.error(`could not parse upload response: ${e}`);
             return null;
         }
     }
 
     function addNewUploadedHubToTable(hub) {
         // hub is the list of rows the server holds for the hub this upload went into:
         // the file itself, the hub.txt, and a row per directory. Add the ones the table
         // has not seen and refresh the ones it has
         let table = $("#filesTable").DataTable();
         let hubDirData = {}; // the data for the parentDir of the uploaded file
         // index the table once: hub carries every row of the hub, so looking each one
         // up by scanning the table would be quadratic on a hub with many files
         let rowIndexByPath = {};
         table.rows().every(function() {
             rowIndexByPath[this.data().fullPath] = this.index();
         });
         for (let obj of hub) {
             if (!obj.parentDir) {
                 hubDirData = obj;
             }
             if (!(obj.fullPath in uiState.filesHash)) {
                 table.row.add(obj);
                 uiState.fileList.push(obj);
                 // NOTE: we don't add the obj to the filesHash until after we're done
                 // so we don't need to reparse all files each time we add one
             } else {
                 // Row already in the table, take the server's values for it. An upload
                 // changes more than its own row: a 2bit flips every row in the hub to
                 // assemblyHub, and a re-upload changes size, md5sum and times
                 Object.assign(uiState.filesHash[obj.fullPath], obj);
                 if (obj.fullPath in rowIndexByPath) {
                     table.row(rowIndexByPath[obj.fullPath]).invalidate();
                 }
             }
         }
 
         // show all the new rows we just added, note the double draw, we need
         // to have the new rows rendered to do the order because the order
         // will copy the actual DOM node
         parseFileListIntoHash(uiState.fileList);
         // stay in the directory the user has open, the upload may have gone into a
         // subdirectory of the hub and would not be listed at the hub level. Both calls
         // have to name the same directory, or the row moved into the header and the row
         // dropped from the table are different ones
         let showDirData = hubDirData;
         if (uiState.currentHubPath && uiState.currentHubPath in uiState.filesHash) {
             showDirData = uiState.filesHash[uiState.currentHubPath];
         }
         if (showDirData.fullPath) {
             dataTableShowDir(table, showDirData.fileName, showDirData.fullPath);
             dataTableCustomOrder(table, showDirData);
         } else {
             // no directory to open, so show everything rather than filter on a
             // path we do not have
             dataTableShowTopLevel(table);
             dataTableCustomOrder(table);
             dataTableEmptyBreadcrumb(table);
         }
         table.draw();
     }
 
     function doRowSelect(evtype, table, indexes) {
         let selectedRow = table.row(indexes);
         let rowTr = selectedRow.node();
         if (rowTr) {
             handleCheckboxSelect(evtype, table, selectedRow);
         }
     }
 
     function indentActionButton(rowTr, rowData) {
         let numIndents = "0px"; //data.parentDir !== "" ? data.fullPath.split('/').length - 1: 0;
         if (rowData.fileType !== "dir") {
             numIndents = "10px";
         }
         rowTr.childNodes[1].style.textIndent = numIndents;
     }
 
     let tableInitOptions = {
         select: {
             items: 'row',
             selector: 'td:first-child',
             style: 'multi+shift', // default to a single click is all that's needed
         },
         pageLength: 25,
         autoWidth: false,     // let the browser's default table-layout:auto size columns,
                               // so they shrink/grow with the container on window resize
         scrollY: 600,
         scrollCollapse: true, // when less than scrollY height is needed, make the table shorter
         deferRender: true, // only draw into the DOM the nodes we need for each page
         orderCellsTop: true, // when viewing a subdirectory, the directory becomes a part of
                              // the header, this option prevents those cells from being used to
                              // sort the table
         layout: {
             top2Start: {
                 div: {
                     className: "",
                     id: "breadcrumb",
                     html: "<span id=\"rootBreadcrumb\" class=\"breadcrumb\">My Data</span>",
                 }
             },
             topStart: {
                 buttons: [
                     {
                         text: 'Upload',
                         action: function() {return;},
                         className: 'uploadButton',
                         enabled: false, // disable by default in case user is not logged in
                     },
                 ],
                 quota: null,
             },
         },
         columnDefs: [
             {
                 orderable: false, targets: 0,
                 render: DataTable.render.select(),
             },
             {
                 orderable: false, targets: 1,
                 data: "action", title: "",
                 render: function(data, type, row) {
                     if (type === "display") {
                         return dataTablePrintAction(row);
                     }
                     return '';
                 }
             },
             {
                 targets: 2,
                 render: function(data, type, row, meta) {
                     let decodedName = decodeURIComponent(data);
                     if (type !== "display") {
                         return decodedName;
                     }
                     if (typeof uiState.userUrl === "undefined" || uiState.userUrl.length === 0) {
                         return decodedName;
                     }
                     if (row.fileType === "dir") {
                         // top-level hubs get an icon that copies a shareable connect link
                         if (!row.parentDir && hubHasHubTxt(row.fullPath)) {
                             let hubLink = hubShareLink(row.fullPath);
                             if (hubLink) {
                                 let hubCopyIcon = copyLinkIconSvg("Copy a shareable link that connects this hub", hubLink);
                                 return '<span style="white-space:nowrap">' + decodedName + hubCopyIcon + '</span>';
                             }
                         }
                         return decodedName;
                     }
                     let fileUrl = uiState.userUrl + cgiEncode(row.fullPath);
                     let copyIcon = copyLinkIconSvg("Copy file URL to clipboard", fileUrl);
                     return '<span style="white-space:nowrap"><a class="fileLink" href="' + fileUrl + '" target="_blank" rel="noopener">' + decodedName + '</a>' + copyIcon + '</span>';
                 }
             },
             {
                 targets: 3,
                 render: function(data, type, row, meta) {
                     if (type === "display") {
                          return dataTablePrintSize(data, type, row, meta);
                     }
                     return data;
                 }
             },
             {
                 targets: 5,
                 render: function(data, type, row) {
                     if (type === "display") {
                         return dataTablePrintGenome(data);
                     }
                     return data;
                 }
             },
             {
                 targets: 6,
                 render: function(data, type, row) {
                     if (type === "display") {
                         return cgiDecode(data);
                     }
                     return data;
                 }
             },
             {
                 targets: [7, 8],
                 className: "nowrap",
                 visible: true,
                 searchable: false,
                 orderable: true,
             },
             {
                 targets: 9,
                 visible: false,
                 searchable: false,
                 orderable: true,
             }
         ],
         columns: [
             {data: "", },
             {data: "", },
             {data: "fileName", title: "File name"},
             {data: "fileSize", title: "File size"},
             {data: "fileType", title: "File type"},
             {data: "genome", title: "Genome"},
             {data: "parentDir", title: "Hubs"},
             {data: "lastModified", title: "File Last Modified", render: renderTimeCell},
             {data: "uploadTime", title: "Upload Time", name: "uploadTime", render: renderTimeCell},
             {data: "fullPath", title: "fullPath", name: "fullPath"},
         ],
         drawCallback: function(settings) {
             console.log("table draw");
         },
         rowCallback: function(row, data, displayNum, displayIndex, dataIndex) {
             // row is a tr element, data is the td values
             // a row can represent one of three things:
             // a 'folder', with no parents, but with children
             // a folder with parents and children (can only come from hubtools
             // a 'file' with no children, but with parentDir
             // we assign the appropriate classes which are used later to
             // collapse/expand and select rows for viewing or deletion
             if (!data.parentDir) {
                 row.className = "topLevelRow";
             } else {
                 row.className = "childRow";
             }
             if (data.fileType === "dir") {
                 row.className += " parentRow";
             }
             indentActionButton(row, data);
         },
         initComplete: function(settings, json) {
             console.log("data loaded, only showing directories");
             let table = new $.fn.dataTable.Api(settings);
             dataTableShowTopLevel(table);
             dataTableCustomOrder(table);
             table.draw();
         }
     };
 
     function showExistingFiles(d) {
         // Make the DataTable for each file
         // make buttons have the same style as other buttons
         if ($.fn.dataTable.isDataTable("#filesTable")) {
             return $("#filesTable").DataTable();
         }
 
         $.fn.dataTable.Buttons.defaults.dom.button.className = 'button';
         tableInitOptions.data = d;
         if (uiState.isLoggedIn) {
             tableInitOptions.language = {emptyTable: "Uploaded files will appear here. Click \"Upload\" to get started"};
         } else {
             tableInitOptions.language = {emptyTable: "You are not logged in, please <a href=\"../cgi-bin/hgSession\">log in or create an account</a> to begin uploading files"};
         }
         DataTable.feature.register('quota', function(settings, opts) {
             let options = Object.assign({option1: false, option2: false}, opts);
             let container = document.createElement("div");
             container.id = "quotaDiv";
             if (uiState.isLoggedIn) {
                 container.textContent = `Using ${prettyFileSize(uiState.userQuota)} of ${prettyFileSize(uiState.maxQuota)}`;
             }
             return container;
         });
         let table = new DataTable("#filesTable", tableInitOptions);
         // Re-sync the scrollY head/body column widths after a resize settles.
         let resizeTimer = null;
         window.addEventListener("resize", function() {
             clearTimeout(resizeTimer);
             resizeTimer = setTimeout(function() {
                 table.columns.adjust();
             }, 100);
         });
         if (uiState.isLoggedIn) {
             table.buttons(".uploadButton").enable();
             document.getElementById("rootBreadcrumb").addEventListener("click", function(e) {
                 dataTableShowTopLevel(table);
                 dataTableCustomOrder(table);
                 dataTableEmptyBreadcrumb(table);
                 table.draw();
             });
         } else {
             table.buttons(".uploadButton").disable();
         }
         let hubBannerBtn = document.getElementById("hubBannerViewBtn");
         if (hubBannerBtn) {
             hubBannerBtn.addEventListener("click", function(e) {
                 viewHubInGenomeBrowser(hubRootForCurrentDir());
             });
         }
         let hubBannerCopyBtn = document.getElementById("hubBannerCopyBtn");
         if (hubBannerCopyBtn) {
             hubBannerCopyBtn.addEventListener("click", copyHubLinkFromBanner);
         }
         table.on("select", function(e, dt, type, indexes) {
             indexes.forEach(function(i) {
                 doRowSelect(e.type, dt, i);
             });
         });
         table.on("deselect", function(e, dt, type, indexes) {
             indexes.forEach(function(i) {
                 doRowSelect(e.type, dt, i);
             });
         });
         table.on("click", function(e) {
             let copyIcon = e.target.closest ? e.target.closest(".copyLinkIcon") : null;
             if (copyIcon) {
                 e.stopPropagation();
                 e.preventDefault();
                 let url = copyIcon.getAttribute("data-url");
                 navigator.clipboard.writeText(url).then(function() {
                     let feedback = document.createElement("span");
                     feedback.textContent = "copied";
                     feedback.style.marginLeft = "6px";
                     feedback.style.fontSize = "0.85em";
                     feedback.style.color = "#080";
                     copyIcon.parentNode.replaceChild(feedback, copyIcon);
                     setTimeout(function() {
                         if (feedback.parentNode) {
                             feedback.parentNode.replaceChild(copyIcon, feedback);
                         }
                     }, 1500);
                 }, function() {
                     alert("Failed to copy URL: " + url);
                 });
                 return;
             }
             if (e.target.closest && e.target.closest(".fileLink")) {
                 e.stopPropagation();
                 return;
             }
             if (e.target.className !== "dt-select-checkbox") {
                 e.stopPropagation();
                 // we've clicked somewhere not on the checkbox itself, we need to:
                 // 1. open the directory if the clicked row is a directory
                 // 2. select the file if the clicked row is a regular file
                 let row = table.row(e.target);
                 let data = row.data();
                 if (data.children && data.children.length > 0) {
                     dataTableShowDir(table, data.fileName, data.fullPath);
                     dataTableCustomOrder(table, data);
                     table.draw();
                 } else {
                     if (row.selected()) {
                         row.deselect();
                         doRowSelect("deselect", table, row.index());
                     } else {
                         row.select();
                         doRowSelect("select", table, row.index());
                     }
                 }
             }
         });
         return table;
     }
 
     function handleGetFileList(jsonData, textStatus) {
         _.assign(uiState, jsonData.userFiles);
         if (uiState.fileList) {
             parseFileListIntoHash(uiState.fileList);
         }
 
         // 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
             onAfterResponse: rememberTusResponseBody,
         };
 
         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: []});
             // Long-duration banner so the user has time to read the message;
             // the StatusBar truncates to "Upload failed" and hides the rest
             // behind a "?" icon.
             uppy.info(cleanMsg, 'error', 30000);
             // Genome-name collision is fixable in place by editing the 2bit's
             // genome field, so reopen the file card.
             if (file && cleanMsg && cleanMsg.includes(hubGenomeCollisionErrFrag)) {
                 const dash = uppy.getPlugin("Dashboard");
                 if (dash) dash.toggleFileCard(true, file.id);
             }
         });
         uppy.on('upload-success', (file, response) => {
             // the file is on the server whatever the table does with it
             updateQuota(file.meta.fileSize);
             // uppy resolves this file's upload only after every upload-success listener
             // has returned, so an error thrown here leaves the batch unfinished and the
             // dialog open. The upload itself has already succeeded, keep it that way
             try {
                 let hub = uploadedHubFromResponse(response);
                 if (hub) {
                     addNewUploadedHubToTable(hub);
                 } else {
                     // the hook reports the rows it wrote, so an empty body means the
                     // table cannot be updated without asking the server again
                     console.error(`upload of '${file.meta.fileName}' returned no file list`);
                     uppy.info(`'${file.meta.fileName}' uploaded, but this page could not ` +
                         `be updated to show it. Reload the page to see your files.`,
                         'warning', 10000);
                 }
             } catch (e) {
                 console.error(`could not show '${file.meta.fileName}' in the table:`, e);
             }
         });
         uppy.on('complete', (result) => {
             history.replaceState(uiState, "", document.location.href);
             console.log("replace history with uiState");
         });
         inited = true;
     }
 
     function checkJsonData(jsonData, callerName) {
         // Return true if jsonData isn't empty and doesn't contain an error;
         // otherwise complain on behalf of caller.
         if (! jsonData) {
             alert(callerName + ': empty response from server');
         } else if (jsonData.error) {
             console.error(jsonData.error);
             alert(callerName + ': error from server: ' + jsonData.error);
         } else if (jsonData.warning) {
             alert("Warning: " + jsonData.warning);
             return true;
         } else {
             if (debugCartJson) {
                 console.log('from server:\n', jsonData);
             }
             return true;
         }
         return false;
     }
 
     function handleRefreshState(jsonData, textStatus) {
         if (checkJsonData(jsonData, 'handleRefreshState')) {
             handleGetFileList(jsonData, true);
         }
     }
 
     function handleErrorState(jqXHR, textStatus) {
         cart.defaultErrorCallback(jqXHR, textStatus);
     }
 
     let inited = false; // keep track of first init for tab switching purposes
     function init() {
         cart.setCgiAndUrl(fileListEndpoint);
         cart.debug(debugCartJson);
         // get the file list immediately upon page load
         let activeTab = $("#tabs").tabs( "option", "active" );
         if (activeTab === 3) {
             let url = new URL(window.location.href);
             if (url.protocol === "http:") {
                 warn(`The hub upload feature is only available over HTTPS. Please load the HTTPS version of ` +
                         `our site: <a href="https:${url.host}${url.pathname}${url.search}">https:${url.host}${url.pathname}${url.search}</a>`);
             } else if ((url.protocol + "//" + url.host) !== loginHost) {
                 warn(`The hub upload feature is only avaiable on our US based public site (<a href="${loginHost}">${loginHost}</a>) for speed purposes. Please go there to upload your hubs, copy the links to the hub.txt files, then use the Connected Hubs tab here to view your files.`);
             } else if (!inited && isLoggedIn) {
                 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,
            };
 }());