c6d419ecfb6f122d7a0cb99c37f56c32c089bea3 chmalee Thu Jan 22 10:41:38 2026 -0800 HubSpace changes: Allow users to overwrite already uploaded files, but warn them first. When a hub.txt file is being uploaded, don't auto-generate one. Check if the tracks being uploaded already exist in the hub.txt before adding a default stanza for it. Add some more checks in the hook files to prevent faulty uploads. Have the tus client clear localStorage on successful uploads so re-uploads of the same file don't cause confusing errors diff --git src/hg/js/hgMyData.js src/hg/js/hgMyData.js index b639768ccff..8b80d778fe6 100644 --- src/hg/js/hgMyData.js +++ src/hg/js/hgMyData.js @@ -287,30 +287,54 @@ return fields; }, 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 + + // Check if any file is a hub.txt - if so, we need to upload it first + // and switch to sequential uploads to prevent race conditions + let hasHubTxt = Object.values(files).some(f => f.meta.fileType === "hub.txt"); + if (hasHubTxt) { + // Set TUS plugin to sequential uploads (limit: 1) + const tusPlugin = uppy.getPlugin('Tus'); + if (tusPlugin) { + tusPlugin.setOptions({ limit: 1 }); + } + // Reorder files so hub.txt comes first (JS objects maintain insertion order) + let hubTxtFiles = {}; + let otherFiles = {}; + for (let [key, file] of Object.entries(files)) { + if (file.meta.fileType === "hub.txt") { + hubTxtFiles[key] = file; + } else { + otherFiles[key] = file; + } + } + files = Object.assign({}, hubTxtFiles, otherFiles); + } + for (let [key, file] of Object.entries(files)) { let fileNameMatch = file.meta.name.match(fileNameRegex); let parentDirMatch = file.meta.parentDir.match(parentDirRegex); 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); doUpload = false; continue; } if (!parentDirMatch || parentDirMatch[0] !== file.meta.parentDir) { uppy.info(`Error: Hub name has special characters, please rename hub: ${file.meta.parentDir} for file: ${file.meta.name} to only include alpha-numeric characters, period, or underscore.`, 'error', 5000); doUpload = false; continue; } if (!file.meta.genome) { uppy.info(`Error: No genome selected for file ${file.meta.name}!`, 'error', 5000); @@ -318,53 +342,65 @@ continue; } if (!file.meta.fileType) { uppy.info(`Error: File type not supported, file: ${file.meta.name}!`, 'error', 5000); 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) { genome = hubCreate.uiState.filesHash[file.meta.parentDir].genome; uppy.info(`Error: the hub ${file.meta.parentDir} already exists and is for genome "${genome}". Please select the correct genome, a different hub or make a new hub.`); doUpload = false; continue; } - // check if the user is uploading a hub.txt into a hub that already has a hub.txt + // 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; - if (file.meta.fileType === "hub.txt" && hubFiles.filter((f) => f.fileType === "hub.txt").length !== 0) { - uppy.info(`Error: the hub definition file (ex: hub.txt) already exists, create a new hub if you want to upload this hub definition file`); - doUpload = false; - continue; + for (let j = 0; j < hubFiles.length; j++) { + if (hubFiles[j].fileName === file.meta.name) { + filesToOverwrite.push(file); + break; + } } } - uppy.setFileMeta(file.id, { - fileName: file.meta.name, - fileSize: file.size, - lastModified: file.data.lastModified, - }); + // 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 "); + 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; + // Return the (possibly reordered) files object to proceed, or false to cancel + return doUpload ? files : false; }, }); // 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"); @@ -691,30 +727,32 @@ let url = "../cgi-bin/hgTracks?hgsid=" + getHgsid() + "&db=" + genome + "&hubUrl=" + encodeURIComponent(hubUrl) + "&" + trackHubFixName(fname) + "=pack"; window.location.assign(url); return false; } } } function trackHubFixName(trackName) { // replace everything but alphanumeric and underscore with underscore return encodeURIComponent(trackName.replaceAll(fileNameFixRegex, "_")); } // 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) => { if (!genome) { genome = d.genome; url += "&db=" + genome; } if (d.fileType === "hub.txt") { url += "&hubUrl=" + encodeURIComponent(uiState.userUrl + cgiEncode(d.fullPath)); @@ -742,102 +780,102 @@ } }); 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; + // 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, genome: d.genome, fullPath: d.fullPath, }); }); cart.send(cartData, deleteFileSuccess); cart.flush(); } function updateSelectedFileDiv(data, isFolderSelect = false) { // update the div that shows how many files are selected let numSelected = data !== null ? data.length : 0; - // if a hub.txt file is in data, disable the delete button - let disableDelete = false; - if (data) { - disableDelete = data.filter((obj) => obj.fileType === "hub.txt").length > 0; - } let infoDiv = document.getElementById("selectedFileInfo"); let span = document.getElementById("numberSelectedFiles"); let spanParentDiv = span.parentElement; if (numSelected > 0) { if (isFolderSelect || span.textContent.endsWith("hub") || span.textContent.endsWith("hubs")) { span.textContent = `${numSelected} ${numSelected > 1 ? "hubs" : "hub"}`; } else { span.textContent = `${numSelected} ${numSelected > 1 ? "files" : "file"}`; } // (re) set up the handlers for the selected file info div: let viewBtn = document.getElementById("viewSelectedFiles"); viewBtn.addEventListener("click", viewAllInGenomeBrowser); viewBtn.textContent = "View selected"; - if (!disableDelete) { let deleteBtn = document.getElementById("deleteSelectedFiles"); deleteBtn.style.display = "inline-block"; deleteBtn.addEventListener("click", deleteFileList); deleteBtn.textContent = "Delete selected"; - } else { - // delete the old button: - let deleteBtn = document.getElementById("deleteSelectedFiles"); - deleteBtn.style.display = "none"; - } } else { span.textContent = ""; } // set the visibility of the placeholder text and info text spanParentDiv.style.display = numSelected === 0 ? "none": "block"; let placeholder = document.getElementById("placeHolderInfo"); placeholder.style.display = numSelected === 0 ? "block" : "none"; } function handleCheckboxSelect(evtype, table, selectedRow) { // depending on the state of the checkbox, we will be adding information // to the div, or removing information. We also potentially checked/unchecked // all of the checkboxes if the selectAll box was clicked. // The data variable will hold all the information we want to keep visible in the info div let data = []; // The selectedData global holds the actual information needed for the view/delete buttons // to work, so data plus any child rows selectedData = {}; + // Track only the rows the user directly selected (not children) + directlySelected = {}; // get all of the currently selected rows (may be more than just the one that // was most recently clicked) table.rows({selected: true}).data().each(function(row, ix) { data.push(row); selectedData[row.fullPath] = row; + directlySelected[row.fullPath] = row; // add any newly checked rows children to the selectedData structure for the view/delete if (row.children) { row.children.forEach(function(child) { selectedData[child.fullPath] = child; }); } }); updateSelectedFileDiv(data, selectedRow.data().fileType === "dir"); } function createOneCrumb(table, dirName, dirFullPath, doAddEvent) { // make a new span that can be clicked to nav through the table let newSpan = document.createElement("span"); newSpan.id = dirName; newSpan.textContent = decodeURIComponent(dirName); @@ -1140,30 +1178,45 @@ // maybe the hub directory row and hub.txt which we may have already seen before let table = $("#filesTable").DataTable(); let justUploaded = {}; // hash of contents of hub but keyed by fullPath let hubDirData = {}; // the data for the parentDir of the uploaded file for (let obj of hub) { if (!obj.parentDir) { hubDirData = obj; } let rowObj; if (!(obj.fullPath in uiState.filesHash)) { justUploaded[obj.fullPath] = obj; rowObj = table.row.add(obj); uiState.fileList.push(obj); // NOTE: we don't add the obj to the filesHash until after we're done // so we don't need to reparse all files each time we add one + } else { + // File already exists - update the existing row with new data (for overwrites) + let existingObj = uiState.filesHash[obj.fullPath]; + existingObj.fileSize = obj.fileSize; + existingObj.lastModified = obj.lastModified; + existingObj.uploadTime = obj.uploadTime; + // Find and invalidate the row in DataTable to refresh display + let allRows = table.rows().indexes(); + for (let j = 0; j < allRows.length; j++) { + let rowData = table.row(allRows[j]).data(); + if (rowData.fullPath === obj.fullPath) { + table.row(allRows[j]).invalidate(); + break; + } + } } } // show all the new rows we just added, note the double draw, we need // to have the new rows rendered to do the order because the order // will copy the actual DOM node parseFileListIntoHash(uiState.fileList); dataTableShowDir(table, hubDirData.fileName, hubDirData.fullPath); table.draw(); dataTableCustomOrder(table, hubDirData); table.columns.adjust().draw(); } function doRowSelect(evtype, table, indexes) { let selectedRow = table.row(indexes); @@ -1394,30 +1447,31 @@ if (uiState.fileList) { parseFileListIntoHash(uiState.fileList); } // first add the top level directories/files let table = showExistingFiles(uiState.fileList); table.columns.adjust().draw(); 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-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; newReqObj = { "fileName": cgiEncode(metadata.fileName), "fileSize": metadata.fileSize,