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