diff --git a/static/app.js b/static/app.js index 48887e9..b34782d 100644 --- a/static/app.js +++ b/static/app.js @@ -4,6 +4,7 @@ const STORE_NAME = "uploads"; const CONCURRENCY = 3; const MAX_RETRIES = 5; const BASE_RETRY_DELAY_MS = 500; +const COMPLETE_EXISTS_MESSAGE = "complete file already exists"; const fileInput = document.querySelector("#file-input"); const pickButton = document.querySelector("#pick-button"); @@ -151,12 +152,12 @@ function renderPendingRecords() { title.textContent = record.name; const detail = document.createElement("span"); - detail.textContent = `${formatBytes(record.size)} - ${record.completed_chunks ?? 0} of ${record.total_chunks} chunks`; + detail.textContent = savedUploadDetail(record); const resume = document.createElement("button"); resume.type = "button"; resume.className = "secondary"; - resume.textContent = "Resume"; + resume.textContent = isReadyToFinish(record) ? "Finish" : "Resume"; resume.addEventListener("click", () => { void resumePendingRecord(record); }); @@ -164,7 +165,7 @@ function renderPendingRecords() { const remove = document.createElement("button"); remove.type = "button"; remove.className = "danger"; - remove.textContent = "Remove"; + remove.textContent = "Clear"; remove.addEventListener("click", () => { void deleteRecord(record.upload_id); }); @@ -192,6 +193,8 @@ function renderButtons() { startButton.disabled = !state.file || state.running || Boolean(state.record); pauseButton.disabled = !state.running; resumeButton.disabled = !state.file || state.running || !state.record; + resumeButton.textContent = + state.record && isReadyToFinish(state.record) ? "Finish" : "Resume"; } function updateProgress(completedCount, totalChunks) { @@ -212,6 +215,44 @@ function findPendingRecord(file) { return state.pendingRecords.find((record) => sameFile(record, file)) ?? null; } +function completedChunkCount(record) { + return Math.min(record.completed_chunks ?? 0, record.total_chunks ?? 0); +} + +function isReadyToFinish(record) { + const totalChunks = record.total_chunks ?? 0; + return totalChunks === 0 || completedChunkCount(record) >= totalChunks; +} + +function savedUploadDetail(record) { + const totalChunks = record.total_chunks ?? 0; + const completedChunks = completedChunkCount(record); + + if (isReadyToFinish(record)) { + return `${formatBytes(record.size)} - ready to finish`; + } + + if (completedChunks === 0) { + return `${formatBytes(record.size)} - not uploaded yet`; + } + + return `${formatBytes(record.size)} - ${completedChunks} of ${totalChunks} chunks uploaded`; +} + +function clearSelection({ resetProgress = true } = {}) { + state.file = null; + state.fileHandle = null; + state.record = null; + state.completedChunks = new Set(); + state.resumeAfterReselect = null; + fileInput.value = ""; + renderFile(null); + if (resetProgress) { + updateProgress(0, 0); + } + renderButtons(); +} + async function selectFile(file, fileHandle = null, record = null) { if (record && !sameFile(record, file)) { log("Selected file does not match the pending upload."); @@ -227,7 +268,13 @@ async function selectFile(file, fileHandle = null, record = null) { updateProgress(state.record?.completed_chunks ?? 0, state.record?.total_chunks ?? 0); renderButtons(); - log(state.record ? "Ready to resume upload." : "Ready to create an upload record."); + if (!state.record) { + log("Ready to create an upload record."); + } else if (isReadyToFinish(state.record)) { + log("Ready to finish saved upload."); + } else { + log("Ready to resume upload."); + } } async function pickFile() { @@ -297,7 +344,11 @@ async function resumePendingRecord(record) { } state.resumeAfterReselect = record; - log("Select the same file to resume."); + log( + isReadyToFinish(record) + ? "Select the same file to finish." + : "Select the same file to resume.", + ); fileInput.click(); } @@ -357,19 +408,21 @@ async function runUpload() { } log("Completing upload."); - const complete = await fetchJson(`/api/uploads/${state.record.upload_id}/complete`, { + const uploadId = state.record.upload_id; + const complete = await fetchJson(`/api/uploads/${uploadId}/complete`, { method: "POST", signal: controller.signal, }); updateProgress(progress.total_chunks, progress.total_chunks); log(`Complete: ${complete.file_path}`); - await deleteRecord(state.record.upload_id); - state.completedChunks = new Set(); - state.record = null; + await deleteRecord(uploadId); + clearSelection({ resetProgress: false }); } catch (error) { if (controller.signal.aborted || isAbortError(error)) { log("Upload paused."); + } else if (await handleTerminalUploadError(error)) { + controller.abort(); } else { controller.abort(); log(`Upload failed: ${error.message}`); @@ -383,6 +436,30 @@ async function runUpload() { } } +async function handleTerminalUploadError(error) { + if (!state.record || typeof error.status !== "number") { + return false; + } + + const uploadId = state.record.upload_id; + + if (error.status === 404) { + await deleteRecord(uploadId); + clearSelection(); + log("Saved upload progress no longer exists on the server."); + return true; + } + + if (error.status === 409 && error.message === COMPLETE_EXISTS_MESSAGE) { + await deleteRecord(uploadId); + clearSelection(); + log("The completed file already exists on the server. Cleared saved upload progress."); + return true; + } + + return false; +} + async function createUploadRecord() { const response = await fetchJson("/api/uploads", { method: "POST", @@ -478,10 +555,18 @@ async function runPool(items, worker) { await Promise.all(workers); } +class ApiRequestError extends Error { + constructor(message, status) { + super(message); + this.name = "ApiRequestError"; + this.status = status; + } +} + async function fetchJson(url, options = {}) { const response = await fetch(url, options); if (!response.ok) { - throw new Error(await responseError(response)); + throw new ApiRequestError(await responseError(response), response.status); } return response.json(); } diff --git a/static/index.html b/static/index.html index c315eca..309f6ed 100644 --- a/static/index.html +++ b/static/index.html @@ -15,7 +15,6 @@
Resumable uploads to this machine.
- Server online