55efeb031b7d8367e8be67cd05fad21c2e592170 chmalee Mon Aug 10 11:59:10 2026 -0700 hubSpace: take the batch genome from hub.txt, and always say why an upload was refused, refs #37972 Co-Authored-By: Claude Opus 5 (1M context) diff --git src/hg/js/hgMyData.js src/hg/js/hgMyData.js index 13bbe361a37..61279a1f70d 100644 --- src/hg/js/hgMyData.js +++ src/hg/js/hgMyData.js @@ -112,51 +112,62 @@ 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"); @@ -270,31 +281,31 @@ 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 || (isTwoBit && batchHasHubTxt)) { + } 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 || "", @@ -532,81 +543,86 @@ doUpload = false; continue; } // check if this hub already exists and the genome is different from what was // just selected, if so, make the user create a new hub if (file.meta.parentDir in hubCreate.uiState.filesHash && hubCreate.uiState.filesHash[file.meta.parentDir].genome !== file.meta.genome) { let existing = hubCreate.uiState.filesHash[file.meta.parentDir]; // 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 { - 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.`); + 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 fileNames = filesToOverwrite.map(f => f.meta.name).join("\n "); + 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.`); + 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: , 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 */ } @@ -747,125 +763,131 @@ 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) { + 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: "assemblyHub", + 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. Lock all siblings to that 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.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); + 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); + 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; @@ -886,62 +908,67 @@ 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() { - // 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 = []; + // 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()) { - if (looksLikeTwoBit(f)) { - let g = f.meta.genome || hubCreate.sanitizeGenomeName(f.name); - if (g && !assemblyHubGenomes.includes(g)) { - assemblyHubGenomes.push(g); + 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 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(", "); + // 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 assemblyHubGenome = null; - if (assemblyHubGenomes.length) { - assemblyHubGenome = assemblyHubGenomes.join(", "); - } + 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"; } @@ -949,48 +976,52 @@ // 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 (assemblyHubGenome) { - // Assembly hub: show the custom genome name as a locked text - // field, no UCSC picker or search. + 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 = assemblyHubGenome; + 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 (assemblyHubGenomes.length > 1) { - note.textContent = "(assembly hub - genome per file is set by genomes.txt; this list shows all genomes in the hub)"; - } else { + 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"; @@ -1100,33 +1131,37 @@ newParent = newRoot; } this.uppy.setFileMeta(file.id, {parentDir: newParent}); } // merging separate hubs under one name can bring two genomes together warnOnMixedGenomes(this.uppy); }); batchSelectDiv.appendChild(batchParentDirLabel); batchSelectDiv.appendChild(batchParentDirInput); // append the batch changes to the bottom of the file list, for some reason // I can't append to the actual Dashboard-files, it must be getting emptied // and re-rendered or something let uppyFilesDiv = document.querySelector(".uppy-Dashboard-progressindicators"); - if (uppyFilesDiv) { - uppyFilesDiv.insertBefore(batchSelectDiv, uppyFilesDiv.firstChild); + 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); }); } } @@ -1307,32 +1342,34 @@ 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 edited on a file card has to reach the batch box too + // 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