Files
upl/static/app.js
T
ddidderr a7b3abd54a fix: render fresh upload progress as empty
A newly selected file has no server upload record yet, so the UI calls the
progress renderer with zero completed chunks and zero total chunks. Treating
that zero-total state as complete made the progress bar jump to 100% before
any upload had started.

Render zero-total progress as empty instead. Existing resumable uploads still
show their server-authoritative completed chunk percentage, and completed
non-empty uploads still render as full because their completed count equals a
non-zero total.

Test Plan:
- just static-check
- just test
- git diff --check
2026-05-30 18:21:54 +02:00

619 lines
16 KiB
JavaScript

const DB_NAME = "upl";
const DB_VERSION = 1;
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");
const fileSummary = document.querySelector("#file-summary");
const fileName = document.querySelector("#file-name");
const fileSize = document.querySelector("#file-size");
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 progressBar = document.querySelector("#progress-bar");
const progressMeta = document.querySelector("#progress-meta");
const pendingSection = document.querySelector("#pending-section");
const pendingList = document.querySelector("#pending-list");
const state = {
abortController: null,
completedChunks: new Set(),
file: null,
fileHandle: null,
pendingRecords: [],
record: null,
resumeAfterReselect: null,
running: false,
};
const dbReady = "indexedDB" in window ? openDatabase() : Promise.resolve(null);
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;
await 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.");
}
state.record = storedRecord;
await loadRecords();
}
async function deleteRecord(uploadId) {
await withStore("readwrite", (store) => store.delete(uploadId));
if (state.record?.upload_id === uploadId) {
state.record = null;
}
await loadRecords();
}
function renderPendingRecords() {
pendingList.replaceChildren();
pendingSection.hidden = state.pendingRecords.length === 0;
for (const record of state.pendingRecords) {
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.addEventListener("click", () => {
void resumePendingRecord(record);
});
const remove = document.createElement("button");
remove.type = "button";
remove.className = "danger";
remove.textContent = "Clear";
remove.addEventListener("click", () => {
void deleteRecord(record.upload_id);
});
meta.append(title, detail);
item.append(meta, resume, remove);
pendingList.append(item);
}
}
function renderFile(file) {
if (!file) {
fileSummary.hidden = true;
fileName.textContent = "";
fileSize.textContent = "";
return;
}
fileName.textContent = file.name;
fileSize.textContent = formatBytes(file.size);
fileSummary.hidden = false;
}
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) {
const percentage = totalChunks === 0 ? 0 : (completedCount / totalChunks) * 100;
progressBar.style.width = `${percentage}%`;
progressMeta.textContent = `${completedCount} of ${totalChunks} chunks`;
}
function sameFile(record, file) {
return (
record.name === file.name &&
record.size === file.size &&
record.last_modified === file.lastModified
);
}
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.");
return;
}
state.file = file;
state.fileHandle = fileHandle;
state.record = record ?? findPendingRecord(file);
state.completedChunks = new Set();
renderFile(file);
updateProgress(state.record?.completed_chunks ?? 0, state.record?.total_chunks ?? 0);
renderButtons();
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() {
if ("showOpenFilePicker" in window) {
try {
const [handle] = await window.showOpenFilePicker({ multiple: false });
const file = await handle.getFile();
await selectFile(file, handle);
return;
} catch (error) {
if (isAbortError(error)) {
return;
}
log(`File picker failed: ${error.message}`);
}
}
fileInput.click();
}
fileInput.addEventListener("change", () => {
const [file] = fileInput.files;
if (!file) {
renderFile(null);
renderButtons();
log("Choose a file to begin.");
return;
}
const record = state.resumeAfterReselect ?? findPendingRecord(file);
state.resumeAfterReselect = null;
void selectFile(file, null, record);
});
pickButton.addEventListener("click", () => {
void pickFile();
});
startButton.addEventListener("click", () => {
void runUpload();
});
pauseButton.addEventListener("click", () => {
if (state.abortController) {
state.abortController.abort();
}
});
resumeButton.addEventListener("click", () => {
void runUpload();
});
async function resumePendingRecord(record) {
if (state.running) {
return;
}
if (record.file_handle) {
const granted = await requestFileHandlePermission(record.file_handle);
if (granted) {
const file = await record.file_handle.getFile();
await selectFile(file, record.file_handle, record);
await runUpload();
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 runUpload() {
if (!state.file || state.running) {
return;
}
const controller = new AbortController();
state.abortController = controller;
state.running = true;
renderButtons();
try {
if (!state.record) {
await createUploadRecord();
}
const progress = await fetchJson(`/api/uploads/${state.record.upload_id}`, {
signal: controller.signal,
});
state.completedChunks = new Set(progress.completed_chunks);
await saveRecord({
...state.record,
completed_chunks: state.completedChunks.size,
chunk_size: progress.chunk_size,
total_chunks: progress.total_chunks,
});
updateProgress(state.completedChunks.size, progress.total_chunks);
const missingChunks = buildMissingChunkList(progress.total_chunks, state.completedChunks);
log(
missingChunks.length === 0
? "All chunks already uploaded."
: `Uploading ${missingChunks.length} missing chunks.`,
);
await runPool(missingChunks, (index) =>
uploadChunkWithRetry(index, progress.chunk_size, progress.total_chunks, controller.signal),
);
if (controller.signal.aborted) {
return;
}
log("Completing upload.");
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(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}`);
}
} finally {
if (state.abortController === controller) {
state.abortController = null;
}
state.running = false;
renderButtons();
}
}
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",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: state.file.name,
size: state.file.size,
last_modified: state.file.lastModified,
}),
});
const record = {
upload_id: response.upload_id,
name: state.file.name,
size: state.file.size,
last_modified: state.file.lastModified,
chunk_size: response.chunk_size,
total_chunks: response.total_chunks,
completed_chunks: 0,
file_handle: state.fileHandle,
updated_at: new Date().toISOString(),
};
await saveRecord(record);
updateProgress(0, response.total_chunks);
log("Upload record created.");
}
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(index, chunkSize, totalChunks, signal) {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
throwIfAborted(signal);
try {
await uploadChunk(index, chunkSize, signal);
state.completedChunks.add(index);
updateProgress(state.completedChunks.size, totalChunks);
await saveRecord({
...state.record,
completed_chunks: state.completedChunks.size,
});
return;
} catch (error) {
if (isAbortError(error) || attempt === MAX_RETRIES) {
throw error;
}
const delayMs = BASE_RETRY_DELAY_MS * 2 ** attempt;
log(`Retrying chunk ${index} after ${delayMs} ms.`);
await delay(delayMs, signal);
}
}
}
async function uploadChunk(index, chunkSize, signal) {
const start = index * chunkSize;
const end = Math.min(state.file.size, start + chunkSize);
const body = state.file.slice(start, end);
const response = await fetch(`/api/uploads/${state.record.upload_id}/chunks/${index}`, {
method: "PUT",
headers: { "Content-Type": "application/octet-stream" },
body,
signal,
});
if (!response.ok) {
throw new Error(await responseError(response));
}
}
async function runPool(items, worker) {
let nextIndex = 0;
const workers = Array.from(
{ length: Math.min(CONCURRENCY, items.length) },
async () => {
while (nextIndex < items.length) {
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();
renderButtons();
}
void initialize();