30a4424ce1b185ef0e6e95969e0b688d686dfb8d chmalee Wed May 6 11:52:40 2026 -0700 HubSpace assembly hub fixes: fixes 1,5,6,8 and prettify error messages, refs #37411 diff --git src/hg/js/hgMyData.js src/hg/js/hgMyData.js index d9298b42ef0..5c23bbf4183 100644 --- src/hg/js/hgMyData.js +++ src/hg/js/hgMyData.js @@ -180,30 +180,35 @@ } 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%" }, @@ -283,36 +288,53 @@ doneButtonHandler: function() { uppy.clear(); }, }; // 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 + // Only one 2bit per hub is supported. propagateAssemblyHubMeta picks + // the first 2bit found and overwrites every sibling's genome with it, + // which silently orphans the second 2bit (lands on disk and in the + // hubSpace table but is not referenced from hub.txt). + let twoBitsInBatch = Object.values(files).filter(looksLikeTwoBit); + if (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. - // Fall back to sanitized filename because setFileMeta is async from - // our point of view - captured meta may not be populated yet. - let batchTwoBit = Object.values(files).find(looksLikeTwoBit); + let batchTwoBit = twoBitsInBatch[0]; if (batchTwoBit) { - let asmGenome = batchTwoBit.meta.genome || hubCreate.sanitizeGenomeName(batchTwoBit.name); + 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; + } for (let f of Object.values(files)) { f.meta.genome = asmGenome; f.meta.genomeLabel = asmGenome; f.meta.hubType = "assemblyHub"; // 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)) { @@ -384,30 +406,46 @@ 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"); } } 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: , 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 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 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. // @@ -668,30 +706,52 @@ if (batchDbSelect && batchDbGenomeSearchBar && batchDbGenomeSearchButton) { let justInitted = initAutocompleteForInput(batchDbGenomeSearchBar.id, batchDbSelect); if (justInitted) { 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) => { + // Only one 2bit per hub is supported. If this batch already has + // a 2bit, reject the new one before any meta-setting runs (which + // would propagate the wrong genome to it). onBeforeUpload also + // enforces this; the duplicate check here keeps the per-file card + // UI from drifting. + if (looksLikeTwoBit(file)) { + let existingTwoBits = this.uppy.getFiles().filter( + f => f.id !== file.id && looksLikeTwoBit(f)); + if (existingTwoBits.length > 0) { + this.uppy.removeFile(file.id); + // setState({error}) puts the message in the StatusBar at + // the bottom of the dashboard where it stays until the + // user clicks Upload (which clears state.error) or closes + // the dashboard. Persistent, unlike uppy.info(). + this.uppy.setState({ + error: `Only one 2bit file per hub is supported. ` + + `Already have "${existingTwoBits[0].name}"; ignoring ` + + `"${file.name}". Split them into separate hubs.` + }); + return; + } + } // add default meta data for genome and fileType let ftype = hubCreate.detectFileType(file.name); let defaultMeta = { "genome": hubCreate.defaultDb(), "fileType": ftype, "parentDir": hubCreate.getDefaultHubName(), "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"; } @@ -1094,30 +1154,52 @@ } }); 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; + let hub = uiState.filesHash[d.parentDir]; + if (!hub || hub.hubType !== "assemblyHub") continue; + if (!selectedHubDirs.has(d.parentDir)) 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 define ` + + `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, @@ -1836,30 +1918,43 @@ // 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 }; 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: []}); + }); uppy.on('upload-success', (file, response) => { const metadata = file.meta; const d = new Date(metadata.lastModified); const pad = (num) => String(num).padStart(2, '0'); const dFormatted = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; const now = new Date(Date.now()); const nowFormatted = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; let newReqObj, hubTxtObj, parentDirObj; let hubType = metadata.hubType || "trackHub"; newReqObj = { "fileName": cgiEncode(metadata.fileName), "fileSize": metadata.fileSize, "fileType": metadata.fileType, "genome": metadata.genome, "parentDir": cgiEncode(metadata.parentDir),