616c17576bf1e99f9452a8b64fc62121b3fd19db chmalee Wed Aug 5 12:39:46 2026 -0700 Refuse a batch that would put more than one genome in a hub we build, refs #37998 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> diff --git src/hg/js/hgMyData.js src/hg/js/hgMyData.js index 04496028b2c..8513d758532 100644 --- src/hg/js/hgMyData.js +++ src/hg/js/hgMyData.js @@ -545,30 +545,45 @@ 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 "); if (!confirm(`The following file(s) already exist and will be overwritten:\n ${fileNames}\n\nContinue?`)) { 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.`); 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; @@ -589,30 +604,115 @@ 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; @@ -948,30 +1048,32 @@ // 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) { uppyFilesDiv.insertBefore(batchSelectDiv, uppyFilesDiv.firstChild); } refreshBatchHubNameInput(this.uppy); // autocomplete only applies in the track-hub path @@ -1141,30 +1243,31 @@ // 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); + 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