60663a461c
A user could select another local file with the same name as one that already exists in completed storage. The upload would be allowed to start and only hit an existing-file conflict late in the flow, which made the UI look like the file was uploadable. Reject duplicate sanitized names during upload creation so no staging record or chunk transfer starts for a file that cannot be completed. Keep the completion path non-replacing as a second guard by promoting through a no-overwrite file creation path, with a hard-link fast path and copy fallback for custom temp locations. The browser now treats the server's duplicate-name conflict as a terminal row: it disables the action, marks the item visually, and tells the user to rename the file if they want to upload that copy. Test Plan: - just check Refs: none
955 lines
24 KiB
JavaScript
955 lines
24 KiB
JavaScript
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();
|