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,