c8c6f609f1998f92db7e4bbb329e252b50f8edd9 chmalee Thu Aug 6 11:42:01 2026 -0700 hubspace: fixing bugs from nightly code review and what Gerardo noted in #37964-note 12 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> diff --git src/hg/js/hgMyData.js src/hg/js/hgMyData.js index 8513d758532..13bbe361a37 100644 --- src/hg/js/hgMyData.js +++ src/hg/js/hgMyData.js @@ -319,31 +319,49 @@ // 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); - let newParentDir = hub ? hub.fileName : hubCreate.uiState.hubNameDefault; + // 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"); @@ -446,31 +464,34 @@ 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 + // 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; } } @@ -985,51 +1006,71 @@ 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); - let newRoot = hub ? hub.fileName : hubCreate.uiState.hubNameDefault; + // 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"; @@ -1192,33 +1233,38 @@ 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) { @@ -1241,30 +1287,50 @@ 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 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: "", @@ -1295,37 +1361,42 @@ "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 hubRootForCurrentDir() { + function hubRootFromPath(path) { // the hub is the first path segment: hub.txt and the hub's own row live there - // even when the user has drilled down into a subdirectory of it - let path = uiState.currentHubPath || uiState.currentHub; + // 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 @@ -1865,94 +1936,96 @@ // 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; - let hubRoot = (d.fileType === "dir") ? d.fullPath : d.parentDir; 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 (!(d.parentDir in hubsAdded)) { + 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. - let hubDir = d.parentDir.replace(/\/$/, ""); - url += "&hubUrl=" + encodeURIComponent(uiState.userUrl + cgiEncode(hubTxtPathForHub(hubDir))); + url += "&hubUrl=" + encodeURIComponent(uiState.userUrl + cgiEncode(hubTxtPathForHub(hubRoot))); } - hubsAdded[d.parentDir] = true; + 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; - let hub = uiState.filesHash[d.parentDir]; + // 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(d.parentDir)) blockedTwoBits.push(d); + 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; } @@ -2331,31 +2404,31 @@ 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, rowData.parentDir, rowData.hubType); + 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();