const DB_NAME = "upl"; const DB_VERSION = 1; const STORE_NAME = "uploads"; const CHUNK_CONCURRENCY = 3; const FILE_CONCURRENCY = 3; const MAX_RETRIES = 5; const BASE_RETRY_DELAY_MS = 500; const FILE_EXISTS_MESSAGE = "file already exists"; const fileInput = document.querySelector("#file-input"); const pickButton = document.querySelector("#pick-button"); const uploadSection = document.querySelector("#upload-section"); const uploadList = document.querySelector("#upload-list"); const startButton = document.querySelector("#start-button"); const pauseButton = document.querySelector("#pause-button"); const resumeButton = document.querySelector("#resume-button"); const eventLog = document.querySelector("#event-log"); const pendingSection = document.querySelector("#pending-section"); const pendingList = document.querySelector("#pending-list"); const state = { pendingRecords: [], resumeAfterReselect: null, schedulerAbortController: null, schedulerRunning: false, uploadItems: [], }; const dbReady = "indexedDB" in window ? openDatabase() : Promise.resolve(null); let nextUploadItemId = 1; let saveChain = Promise.resolve(); function formatBytes(bytes) { const formatter = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1, }); const units = ["B", "KiB", "MiB", "GiB", "TiB"]; let value = bytes; let unit = units[0]; for (const candidate of units) { unit = candidate; if (value < 1024 || candidate === units[units.length - 1]) { break; } value /= 1024; } return `${formatter.format(value)} ${unit}`; } function log(message) { const item = document.createElement("li"); item.textContent = message; eventLog.prepend(item); while (eventLog.children.length > 8) { eventLog.lastElementChild.remove(); } } function openDatabase() { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.addEventListener("upgradeneeded", () => { const db = request.result; if (!db.objectStoreNames.contains(STORE_NAME)) { db.createObjectStore(STORE_NAME, { keyPath: "upload_id" }); } }); request.addEventListener("success", () => resolve(request.result)); request.addEventListener("error", () => reject(request.error)); }).catch((error) => { log(`IndexedDB unavailable: ${error.message}`); return null; }); } function requestToPromise(request) { return new Promise((resolve, reject) => { request.addEventListener("success", () => resolve(request.result)); request.addEventListener("error", () => reject(request.error)); }); } async function withStore(mode, callback) { const db = await dbReady; if (!db) { return null; } const transaction = db.transaction(STORE_NAME, mode); return requestToPromise(callback(transaction.objectStore(STORE_NAME))); } async function loadRecords() { const records = (await withStore("readonly", (store) => store.getAll())) ?? []; records.sort((left, right) => (right.updated_at ?? "").localeCompare(left.updated_at ?? ""), ); state.pendingRecords = records; renderPendingRecords(); } async function saveRecord(record) { const nextSave = saveChain.catch(() => null).then(() => writeRecord(record)); saveChain = nextSave; return nextSave; } async function writeRecord(record) { const storedRecord = { ...record, updated_at: new Date().toISOString() }; try { await withStore("readwrite", (store) => store.put(storedRecord)); } catch (error) { if (!storedRecord.file_handle) { throw error; } delete storedRecord.file_handle; await withStore("readwrite", (store) => store.put(storedRecord)); log("Saved resume state without a reusable file handle."); } await loadRecords(); return storedRecord; } async function deleteRecord(uploadId) { await withStore("readwrite", (store) => store.delete(uploadId)); await loadRecords(); } function renderPendingRecords() { pendingList.replaceChildren(); const activeUploadIds = new Set( state.uploadItems .map((item) => item.record?.upload_id) .filter((uploadId) => Boolean(uploadId)), ); const visibleRecords = state.pendingRecords.filter( (record) => !activeUploadIds.has(record.upload_id), ); pendingSection.hidden = visibleRecords.length === 0; for (const record of visibleRecords) { const item = document.createElement("li"); item.className = "pending-item"; const meta = document.createElement("div"); meta.className = "pending-meta"; const title = document.createElement("strong"); title.textContent = record.name; const detail = document.createElement("span"); detail.textContent = savedUploadDetail(record); const resume = document.createElement("button"); resume.type = "button"; resume.className = "secondary"; resume.textContent = isReadyToFinish(record) ? "Finish" : "Resume"; resume.disabled = state.schedulerRunning || !hasAvailableFileSlot(); resume.addEventListener("click", () => { void resumePendingRecord(record); }); const remove = document.createElement("button"); remove.type = "button"; remove.className = "danger"; remove.textContent = "Clear"; remove.disabled = state.schedulerRunning; remove.addEventListener("click", () => { void deleteRecord(record.upload_id); }); meta.append(title, detail); item.append(meta, resume, remove); pendingList.append(item); } } function renderUploadItems() { uploadList.replaceChildren(); uploadSection.hidden = state.uploadItems.length === 0; for (const item of state.uploadItems) { const row = document.createElement("li"); row.className = "upload-item"; if (item.terminal) { row.classList.add("upload-item-blocked"); row.setAttribute("aria-invalid", "true"); } const header = document.createElement("div"); header.className = "upload-item-header"; const meta = document.createElement("div"); meta.className = "upload-meta"; const title = document.createElement("strong"); title.textContent = item.file.name; const detail = document.createElement("span"); detail.textContent = uploadItemDetail(item); meta.append(title, detail); const actions = document.createElement("div"); actions.className = "upload-item-actions"; const start = document.createElement("button"); start.type = "button"; start.textContent = uploadActionLabel(item); start.disabled = !canRunItem(item) || state.schedulerRunning || !hasAvailableFileSlot(); start.addEventListener("click", () => { void runUploadItem(item); }); const pause = document.createElement("button"); pause.type = "button"; pause.className = "secondary"; pause.textContent = "Pause"; pause.disabled = !item.running; pause.addEventListener("click", () => { item.abortController?.abort(); }); const remove = document.createElement("button"); remove.type = "button"; remove.className = "secondary"; remove.textContent = "Remove"; remove.disabled = item.running || item.queued; remove.addEventListener("click", () => { removeUploadItem(item); }); actions.append(start, pause, remove); header.append(meta, actions); const progress = document.createElement("div"); progress.className = "upload-progress"; const progressWrap = document.createElement("div"); progressWrap.className = "progress-wrap"; progressWrap.setAttribute("aria-label", `${item.file.name} upload progress`); const progressBar = document.createElement("div"); progressBar.className = "progress-bar"; progressBar.style.width = `${progressPercentage( item.completedCount, item.totalChunks, )}%`; const progressMeta = document.createElement("div"); progressMeta.className = "progress-meta"; progressMeta.textContent = `${item.completedCount} of ${item.totalChunks} chunks`; progressWrap.append(progressBar); progress.append(progressWrap, progressMeta); row.append(header, progress); uploadList.append(row); } } function renderButtons() { const hasRunnable = state.uploadItems.some((item) => canRunItem(item)); const hasRunnableResume = state.uploadItems.some( (item) => item.record && canRunItem(item), ); const hasRunningOrQueued = state.uploadItems.some((item) => item.running || item.queued); const hasFileSlot = hasAvailableFileSlot(); startButton.disabled = !hasRunnable || state.schedulerRunning || !hasFileSlot; pauseButton.disabled = !hasRunningOrQueued; resumeButton.disabled = !hasRunnableResume || state.schedulerRunning || !hasFileSlot; } function renderAll() { renderUploadItems(); renderPendingRecords(); renderButtons(); } function progressPercentage(completedCount, totalChunks) { if (totalChunks <= 0) { return 0; } return Math.min(100, Math.max(0, (completedCount / totalChunks) * 100)); } function sameFile(record, file) { return ( record.name === file.name && record.size === file.size && record.last_modified === file.lastModified ); } function sameUploadItemFile(item, file) { return ( item.file.name === file.name && item.file.size === file.size && item.file.lastModified === file.lastModified ); } function findPendingRecord(file) { return state.pendingRecords.find((record) => sameFile(record, file)) ?? null; } function findUploadItem(file, record = null) { return ( state.uploadItems.find((item) => { if (item.finished || item.terminal) { return false; } if (record?.upload_id && item.record?.upload_id === record.upload_id) { return true; } return sameUploadItemFile(item, 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 isUploadItemReadyToFinish(item) { return ( Boolean(item.record) && (item.totalChunks === 0 || item.completedCount >= item.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 uploadItemDetail(item) { return `${formatBytes(item.file.size)} - ${item.statusText}`; } function uploadActionLabel(item) { if (item.terminal) { return "Unavailable"; } if (item.finished) { return "Done"; } if (!item.record) { return "Start"; } return isUploadItemReadyToFinish(item) ? "Finish" : "Resume"; } function initialUploadStatus(record) { if (!record) { return "Ready to create an upload record."; } if (isReadyToFinish(record)) { return "Ready to finish saved upload."; } return "Ready to resume upload."; } function canRunItem(item) { return ( Boolean(item.file) && !item.running && !item.queued && !item.finished && !item.terminal ); } function runningFileCount() { return state.uploadItems.filter((item) => item.running).length; } function hasAvailableFileSlot() { return runningFileCount() < FILE_CONCURRENCY; } function setItemProgress(item, completedCount, totalChunks) { item.totalChunks = Math.max(0, totalChunks); item.completedCount = item.totalChunks === 0 ? 0 : Math.min(Math.max(0, completedCount), item.totalChunks); } async function selectFiles(files, fileHandles = []) { const selectedFiles = Array.from(files); if (selectedFiles.length === 0) { log("Choose files to begin."); renderButtons(); return; } const resumeRecord = state.resumeAfterReselect; let matchedResumeRecord = false; let addedCount = 0; for (const [index, file] of selectedFiles.entries()) { let record = findPendingRecord(file); if (resumeRecord && sameFile(resumeRecord, file)) { record = resumeRecord; matchedResumeRecord = true; } const previousCount = state.uploadItems.length; addUploadItem(file, fileHandles[index] ?? null, record); if (state.uploadItems.length > previousCount) { addedCount += 1; } } if (resumeRecord && !matchedResumeRecord) { log("Selected files did not include the pending upload file."); } state.resumeAfterReselect = null; fileInput.value = ""; renderAll(); if (addedCount === 1) { log("Ready to upload 1 file."); } else if (addedCount > 1) { log(`Ready to upload ${addedCount} files.`); } } function addUploadItem(file, fileHandle = null, record = null) { if (record && !sameFile(record, file)) { log("Selected file does not match the pending upload."); return null; } const existingItem = findUploadItem(file, record); if (existingItem) { log(`${file.name} is already selected.`); return existingItem; } const item = { abortController: null, completedChunks: new Set(), completedCount: record ? completedChunkCount(record) : 0, file, fileHandle, finished: false, id: nextUploadItemId, queued: false, record, running: false, statusText: initialUploadStatus(record), terminal: false, totalChunks: record?.total_chunks ?? 0, }; nextUploadItemId += 1; state.uploadItems.push(item); return item; } function removeUploadItem(item) { if (item.running || item.queued) { return; } state.uploadItems = state.uploadItems.filter((candidate) => candidate.id !== item.id); renderAll(); } async function pickFile() { if ("showOpenFilePicker" in window) { try { const handles = await window.showOpenFilePicker({ multiple: true }); const files = await Promise.all(handles.map((handle) => handle.getFile())); await selectFiles(files, handles); return; } catch (error) { if (isAbortError(error)) { return; } log(`File picker failed: ${error.message}`); } } fileInput.click(); } fileInput.addEventListener("change", () => { void selectFiles(fileInput.files); }); pickButton.addEventListener("click", () => { void pickFile(); }); startButton.addEventListener("click", () => { void runUploadItems(state.uploadItems); }); pauseButton.addEventListener("click", () => { pauseUploads(); }); resumeButton.addEventListener("click", () => { void runUploadItems(state.uploadItems.filter((item) => item.record)); }); async function resumePendingRecord(record) { if (state.schedulerRunning) { return; } if (record.file_handle) { const granted = await requestFileHandlePermission(record.file_handle); if (granted) { const file = await record.file_handle.getFile(); const item = addUploadItem(file, record.file_handle, record); renderAll(); if (item) { await runUploadItem(item); } return; } } state.resumeAfterReselect = record; log( isReadyToFinish(record) ? "Select the same file to finish." : "Select the same file to resume.", ); fileInput.click(); } async function requestFileHandlePermission(handle) { if (!("queryPermission" in handle) || !("requestPermission" in handle)) { return false; } const options = { mode: "read" }; if ((await handle.queryPermission(options)) === "granted") { return true; } return (await handle.requestPermission(options)) === "granted"; } async function runUploadItems(items) { if (state.schedulerRunning) { return; } const runnableItems = items.filter((item) => canRunItem(item)); const availableSlots = FILE_CONCURRENCY - runningFileCount(); if (runnableItems.length === 0 || availableSlots <= 0) { return; } const controller = new AbortController(); state.schedulerAbortController = controller; state.schedulerRunning = true; for (const item of runnableItems) { item.queued = true; item.statusText = "Queued."; } renderAll(); try { await runPool( runnableItems, async (item) => { throwIfAborted(controller.signal); if (!item.queued) { return; } await runUploadItem(item); }, availableSlots, controller.signal, ); } catch (error) { if (!isAbortError(error)) { log(`Upload scheduler failed: ${error.message}`); } } finally { for (const item of runnableItems) { if (item.queued) { item.queued = false; item.statusText = "Paused."; } } if (state.schedulerAbortController === controller) { state.schedulerAbortController = null; } state.schedulerRunning = false; renderAll(); } } function pauseUploads() { state.schedulerAbortController?.abort(); for (const item of state.uploadItems) { if (item.running) { item.abortController?.abort(); } else if (item.queued) { item.queued = false; item.statusText = "Paused."; } } renderAll(); } async function runUploadItem(item) { if ( item.running || item.finished || item.terminal || (!item.queued && !hasAvailableFileSlot()) || (!item.queued && !canRunItem(item)) ) { return; } const controller = new AbortController(); item.abortController = controller; item.queued = false; item.running = true; item.statusText = item.record ? "Checking saved progress." : "Creating upload record."; renderAll(); try { if (!item.record) { await createUploadRecord(item, controller.signal); } const progress = await fetchJson(`/api/uploads/${item.record.upload_id}`, { signal: controller.signal, }); item.completedChunks = new Set(progress.completed_chunks); setItemProgress(item, item.completedChunks.size, progress.total_chunks); item.record = await saveRecord({ ...item.record, completed_chunks: item.completedCount, chunk_size: progress.chunk_size, total_chunks: progress.total_chunks, }); renderAll(); const missingChunks = buildMissingChunkList(progress.total_chunks, item.completedChunks); item.statusText = missingChunks.length === 0 ? "All chunks already uploaded." : `Uploading ${missingChunks.length} missing chunks.`; renderAll(); await runPool( missingChunks, (index) => uploadChunkWithRetry( item, index, progress.chunk_size, progress.total_chunks, controller.signal, ), CHUNK_CONCURRENCY, controller.signal, ); if (controller.signal.aborted) { return; } item.statusText = "Completing upload."; renderAll(); const uploadId = item.record.upload_id; const complete = await fetchJson(`/api/uploads/${uploadId}/complete`, { method: "POST", signal: controller.signal, }); setItemProgress(item, progress.total_chunks, progress.total_chunks); item.finished = true; item.statusText = `Complete: ${complete.file_path}`; await deleteRecord(uploadId); } catch (error) { if (controller.signal.aborted || isAbortError(error)) { item.statusText = "Paused."; } else if (await handleTerminalUploadError(item, error)) { controller.abort(); } else { controller.abort(); item.statusText = `Upload failed: ${error.message}`; } } finally { if (item.abortController === controller) { item.abortController = null; } item.running = false; renderAll(); } } async function handleTerminalUploadError(item, error) { if (typeof error.status !== "number") { return false; } if (isFileExistsConflict(error)) { if (item.record) { await deleteRecord(item.record.upload_id); } item.record = null; item.completedChunks = new Set(); setItemProgress(item, 0, 0); item.terminal = true; item.statusText = "File already exists on the server. Rename the file to upload this copy."; log(`${item.file.name}: file already exists. Rename it to upload this copy.`); return true; } if (!item.record) { return false; } const uploadId = item.record.upload_id; if (error.status === 404) { await deleteRecord(uploadId); item.record = null; item.completedChunks = new Set(); setItemProgress(item, 0, 0); item.statusText = "Saved upload progress no longer exists. Start again."; log(`${item.file.name}: saved upload progress no longer exists on the server.`); return true; } return false; } function isFileExistsConflict(error) { return error.status === 409 && error.message === FILE_EXISTS_MESSAGE; } async function createUploadRecord(item, signal) { const response = await fetchJson("/api/uploads", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: item.file.name, size: item.file.size, last_modified: item.file.lastModified, }), signal, }); const record = { upload_id: response.upload_id, name: item.file.name, size: item.file.size, last_modified: item.file.lastModified, chunk_size: response.chunk_size, total_chunks: response.total_chunks, completed_chunks: 0, file_handle: item.fileHandle, updated_at: new Date().toISOString(), }; item.record = await saveRecord(record); setItemProgress(item, 0, response.total_chunks); item.statusText = "Upload record created."; renderAll(); } function buildMissingChunkList(totalChunks, completedChunks) { const missing = []; for (let index = 0; index < totalChunks; index += 1) { if (!completedChunks.has(index)) { missing.push(index); } } return missing; } async function uploadChunkWithRetry(item, index, chunkSize, totalChunks, signal) { for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) { throwIfAborted(signal); try { await uploadChunk(item, index, chunkSize, signal); item.completedChunks.add(index); setItemProgress(item, item.completedChunks.size, totalChunks); item.record = await saveRecord({ ...item.record, completed_chunks: item.completedCount, }); renderAll(); return; } catch (error) { if (isAbortError(error) || attempt === MAX_RETRIES) { throw error; } const delayMs = BASE_RETRY_DELAY_MS * 2 ** attempt; item.statusText = `Retrying chunk ${index} after ${delayMs} ms.`; renderAll(); await delay(delayMs, signal); } } } async function uploadChunk(item, index, chunkSize, signal) { const start = index * chunkSize; const end = Math.min(item.file.size, start + chunkSize); const body = item.file.slice(start, end); const response = await fetch( `/api/uploads/${item.record.upload_id}/chunks/${index}`, { method: "PUT", headers: { "Content-Type": "application/octet-stream" }, body, signal, }, ); if (!response.ok) { throw new ApiRequestError(await responseError(response), response.status); } } async function runPool(items, worker, concurrency, signal = null) { let nextIndex = 0; const workers = Array.from( { length: Math.min(concurrency, items.length) }, async () => { while (nextIndex < items.length) { if (signal) { throwIfAborted(signal); } const item = items[nextIndex]; nextIndex += 1; await worker(item); } }, ); 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 ApiRequestError(await responseError(response), response.status); } return response.json(); } async function responseError(response) { const text = await response.text(); if (!text) { return `${response.status} ${response.statusText}`; } try { return JSON.parse(text).error ?? text; } catch { return text; } } function delay(ms, signal) { return new Promise((resolve, reject) => { throwIfAborted(signal); const id = window.setTimeout(resolve, ms); signal.addEventListener( "abort", () => { window.clearTimeout(id); reject(new DOMException("Aborted", "AbortError")); }, { once: true }, ); }); } function throwIfAborted(signal) { if (signal.aborted) { throw new DOMException("Aborted", "AbortError"); } } function isAbortError(error) { return error?.name === "AbortError"; } async function initialize() { await loadRecords(); renderAll(); } void initialize();