0ae2a6a36d7b4c77e8e7d7df2a1049f4412e7099
max
  Fri Sep 18 07:03:44 2026 -0700
hgHubConnect: let a mirror hand out its own API keys, and stop swallowing links to another host

#Preview2 week - bugs introduced now will need a build patch to fix
Two of the three problems QA found on #38323.

The link in the mirror-only Hub Upload message did not go anywhere. The tab
handler in hgHubConnect.js catches every hgHubConnect link with a hash and turns
it into a tab switch, and 'Go to Hub Upload on genome.ucsc.edu' has a hash that
names a tab on the mirror too, so the click just reopened the tab the reader was
already on. It now only intercepts links to the page itself.

The API key section was decoupled from storeUserFiles, but only for display: the
Generate and Revoke buttons are cartJson requests, and both the javascript that
sends them and the code in main() that routes them were still inside the
storeUserFiles gate. A site with showHubApiKey on and hubSpace off therefore drew
two dead buttons. The key functions move out of hgMyData.js into a new
hubApiKey.js that the Hub Development tab includes on its own, main() routes a
cartJson request when either setting is on, and the hubSpace file commands stay
registered only when hubSpace is actually running.

The request also goes to this host's hgHubConnect now rather than to the login
host. Keys live in the central database of the server that issues them, so a key
made on genome-euro belongs in genome-euro's table.

refs #38323

diff --git src/hg/js/hgMyData.js src/hg/js/hgMyData.js
index 5a578a838a2..787629ec271 100644
--- src/hg/js/hgMyData.js
+++ src/hg/js/hgMyData.js
@@ -1,3110 +1,3055 @@
 /* jshint esversion: 8 */
 
 /* This file contains all the code needed to get the HubSpace UI functioning.
  * There are some helper functions that are sort of general and could probably
  * be added to utils.js or similar as well as 3 main pieces:
- * - the api key generation/revocation: functions to request new/revoke old apiKeys
- *       this code runs on the Hub Development tab of hgHubConnect
  * - uppyOptions and uppy constructor: Uppy is a 3rd party library for handling
  *       user uploads. The uppyOptions object and constructor are used to modify
  *       the default behavior, including what to do when a file has been added
  *       to the dashboard, verifying file name legality, etc. Code in these sections
  *       also modifies the default preact/react rendering of elements, so it looks
  *       a little different than normal kent javascript.
  * - BatchChangePlugin class: a custom class again used to extend the default Uppy
  *       interface. This time to put some inputs at the bottom of the dashboard
  *       that changes metadata for all the files a user has selected
  * - hubCreate: An IIFE that runs on document ready that sets up or controls the
  *       whole UI. The UI is a combindation of Uppy for the actual file selection
  *       and DataTables for showing the uploaded files. There are many helper functions
  *       within this block that also could probably be moved to a lib, but haven't as
  *       the code has evolved over time.
  *
  *   TODO: most of this code could probably be modularized successfully, or split up
  *   so it is easier to read.
+ *
+ * The API key generation and revocation used to be a fourth piece here. It is in
+ * hubApiKey.js now: the Hub Development tab offers those controls on sites that do not
+ * run hubSpace, and such a site never loads this file.
  */
 
 
 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. 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 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
         // 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 = 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);
     }
 
     function showMirrorOnlyMessage() {
         // On the mirrors there is no upload infrastructure, so instead of layering a
         // dialog over a tab that cannot be used, replace the tab with instructions on
         // where to upload and how to get the uploaded hub back onto this site.
         let uploadUrl = `${loginHost}/cgi-bin/hgHubConnect#hubUpload`;
         let hostName = loginHost.replace(/^https?:\/\//, "");
         $("#hubUpload").html(
             `<div class='tabSection'>` +
             `<h4>Hub upload is only possible on ${hostName}</h4>` +
             `<p>Files can only be uploaded on our main US-based site, for speed reasons. ` +
             `Your uploaded files are stored there and are not copied to this mirror.</p>` +
             `<p><a href="${uploadUrl}" style="color:#121E9A"><b>Go to Hub Upload on ${hostName}</b></a></p>` +
             `<p>A hub that you upload there can be used on any of our sites, including this one. ` +
             `To use one of your uploaded hubs here:</p>` +
             `<ol>` +
             `<li>Upload your files on <a href="${uploadUrl}" style="color:#121E9A">${hostName}</a>.</li>` +
             `<li>In the file table there, right-click the hub.txt file of your hub and select ` +
             `"Copy link" to get its URL.</li>` +
             `<li>Paste this URL into the ` +
             `<a href="hgHubConnect#unlistedHubs" style="color:#121E9A">Connected Hubs</a> tab ` +
             `on this site. You can also build a link that connects the hub automatically, see ` +
             `<a href="../goldenPath/help/hgTrackHubHelp.html#Sharing" ` +
             `style="color:#121E9A" target="_blank">Sharing Track Hubs</a>.</li>` +
             `</ol>` +
             `</div>`);
     }
 
     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" );
         // Which tab this is depends on what the mirror turns on: hgHubConnect only prints
         // the Hub Development tab when hgHubConnect.validateHub is set, so on a mirror with
         // storeUserFiles on and validateHub off, Hub Upload is the third tab and not the
         // fourth.  Find it by its panel instead of counting.
         let hubUploadTab = $('#tabs > ul > li > a[href="#hubUpload"]').parent().index();
         if (hubUploadTab >= 0 && activeTab === hubUploadTab) {
             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) {
                 showMirrorOnlyMessage();
             } 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,
            };
 }());