Files
warpbox-dev/backend/static/js/40-upload.js
Daniel Legt 313c89483c
Some checks failed
Build and Publish Docker Image / deploy (push) Failing after 56s
feat(upload): add resumable chunk configuration and file validation
- Add `WARPBOX_RESUMABLE_CHUNK_MODE` and `WARPBOX_RESUMABLE_CHUNK_PATH` environment variables to configure temporary chunk storage.
- Implement strict file validation for resuming uploads to ensure selected files match the pending session's metadata.
- Add `PLANS.md` to document development stages, roadmap, and API specifications (including batching and resumable flows).
2026-06-02 22:13:54 +03:00

1006 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
const form = document.querySelector("#upload-form");
const dropZone = document.querySelector(".drop-zone");
const fileInput = document.querySelector("#file-input");
const fileSummary = document.querySelector("#file-summary");
const progress = document.querySelector("#upload-progress");
const uploadStatus = document.querySelector("#upload-status");
const result = document.querySelector("#upload-result");
const resultMeta = document.querySelector("#result-meta");
const resultList = document.querySelector("#result-list");
const uploadQueue = document.querySelector("#upload-queue");
const totalProgressBar = document.querySelector("#total-progress-bar");
const copyURL = document.querySelector("#copy-url");
const openBox = document.querySelector("#open-box");
const manageLink = document.querySelector("#manage-link");
const newUpload = document.querySelector("#new-upload");
const RESUMABLE_SESSIONS_KEY = "warpbox-resumable-sessions";
if (!form || !dropZone || !fileInput) {
return;
}
// Remember the last-chosen expiry across uploads (per browser).
const expirySelect = form.querySelector("[data-expiry-select]");
if (expirySelect) {
const EXPIRY_KEY = "warpbox-expiry";
let saved = null;
try {
saved = localStorage.getItem(EXPIRY_KEY);
} catch (e) {
saved = null;
}
if (saved && expirySelect.querySelector('option[value="' + saved + '"]')) {
expirySelect.value = saved;
}
expirySelect.addEventListener("change", () => {
try {
localStorage.setItem(EXPIRY_KEY, expirySelect.value);
} catch (e) {
/* ignore persistence failures */
}
});
}
let latestBoxURL = "";
let selectedFiles = [];
let uploadLocked = false;
let recoveredDraft = null;
let resumeMode = false;
["dragenter", "dragover"].forEach((eventName) => {
dropZone.addEventListener(eventName, (event) => {
event.preventDefault();
dropZone.classList.add("is-dragging");
});
});
["dragleave", "drop"].forEach((eventName) => {
dropZone.addEventListener(eventName, (event) => {
event.preventDefault();
dropZone.classList.remove("is-dragging");
});
});
document.addEventListener("dragover", (event) => {
if (event.dataTransfer && Array.from(event.dataTransfer.types || []).includes("Files")) {
event.preventDefault();
}
});
document.addEventListener("drop", (event) => {
if (!event.dataTransfer || !event.dataTransfer.files.length) {
return;
}
event.preventDefault();
if (!dropZone.contains(event.target)) {
addSelectedFiles(event.dataTransfer.files);
}
});
dropZone.addEventListener("drop", (event) => {
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
addSelectedFiles(event.dataTransfer.files);
}
});
fileInput.addEventListener("change", () => {
addSelectedFiles(fileInput.files);
fileInput.value = "";
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (selectedFiles.length === 0) {
updateStatus("Choose at least one file first.");
return;
}
const submit = form.querySelector("button[type='submit']");
const formData = uploadFormData();
if (resumeMode && recoveredDraft) {
renderResumeQueue(recoveredDraft.session, selectedFiles);
} else {
renderQueue(selectedFiles, "queued");
}
setLoading(true, submit);
try {
const payload = await uploadResumable(form.action, formData, selectedFiles);
renderResult(payload);
form.reset();
selectedFiles = [];
resumeMode = false;
recoveredDraft = null;
fileInput.value = "";
if (uploadQueue) {
uploadQueue.hidden = true;
uploadQueue.replaceChildren();
}
if (newUpload) {
newUpload.hidden = true;
}
if (fileSummary) {
fileSummary.textContent = "Upload complete.";
}
} catch (error) {
updateStatus(error.message || "Upload failed");
} finally {
setLoading(false, submit);
}
});
if (copyURL) {
copyURL.addEventListener("click", () => {
window.Warpbox.copyText(latestBoxURL, copyURL, "Copied");
});
}
if (newUpload) {
newUpload.addEventListener("click", () => {
cancelRecoveredDraft().catch((error) => {
updateStatus(error.message || "Upload draft could not be deleted");
});
});
}
recoverResumableSessions();
function addSelectedFiles(files) {
if (uploadLocked) {
return;
}
Array.from(files || []).forEach((file) => {
if (!selectedFiles.some((existing) => fileIdentity(existing) === fileIdentity(file))) {
selectedFiles.push(file);
}
});
updateSelectedState();
}
function removeSelectedFile(index) {
if (uploadLocked) {
return;
}
selectedFiles.splice(index, 1);
updateSelectedState();
}
function updateSelectedState() {
const count = selectedFiles.length || 0;
const title = dropZone.querySelector(".drop-title");
if (title) {
title.textContent = count === 0 ? "Drop files to upload" : count === 1 ? "1 file selected" : `${count} files selected`;
}
if (fileSummary) {
if (resumeMode && recoveredDraft) {
fileSummary.textContent = count === 0
? "Reselect missing files to resume, or add extra files to this upload."
: `${count} local file${count === 1 ? "" : "s"} ready for the recovered upload.`;
} else {
fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`;
}
}
if (resumeMode && recoveredDraft) {
renderResumeQueue(recoveredDraft.session, selectedFiles);
} else if (count > 0) {
renderQueue(selectedFiles, "queued");
} else if (uploadQueue) {
uploadQueue.hidden = true;
uploadQueue.replaceChildren();
}
if (newUpload) {
newUpload.hidden = !(resumeMode && recoveredDraft);
}
}
function setLoading(isLoading, submit) {
uploadLocked = isLoading;
if (progress) {
progress.hidden = !isLoading;
}
if (submit) {
submit.disabled = isLoading;
submit.textContent = isLoading ? "Uploading..." : "Upload files";
}
if (newUpload) {
newUpload.disabled = isLoading;
}
updateStatus(isLoading ? "Transferring files..." : "");
setTotalProgress(isLoading ? 0 : 100);
}
function updateStatus(message) {
if (uploadStatus) {
uploadStatus.textContent = message;
}
}
function renderResult(payload) {
if (!result || !resultList || !resultMeta || !openBox) {
return;
}
latestBoxURL = window.Warpbox.absoluteURL(payload.boxUrl);
result.hidden = false;
openBox.href = latestBoxURL;
resultMeta.textContent = `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} · expires ${window.Warpbox.formatDate(payload.expiresAt)}`;
if (manageLink) {
const anchor = manageLink.querySelector("a");
manageLink.hidden = !payload.manageUrl;
if (anchor && payload.manageUrl) {
anchor.href = window.Warpbox.absoluteURL(payload.manageUrl);
}
}
resultList.replaceChildren();
payload.files.forEach((file) => {
resultList.append(createFileRow({
name: file.name,
meta: `${file.size} · ${window.Warpbox.absoluteURL(file.url)}`,
progress: 100,
status: "complete",
}));
});
result.scrollIntoView({ behavior: "smooth", block: "start" });
}
function uploadWithProgress(url, formData, files) {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("POST", url);
request.setRequestHeader("Accept", "application/json");
request.upload.addEventListener("progress", (event) => {
if (!event.lengthComputable) {
updateStatus("Uploading...");
return;
}
const percent = Math.round((event.loaded / event.total) * 100);
updateStatus(`${percent}%`);
setTotalProgress(percent);
setFileProgress(files, percent);
});
request.addEventListener("load", () => {
let payload = {};
try {
payload = JSON.parse(request.responseText || "{}");
} catch (error) {
reject(new Error("Upload response could not be read"));
return;
}
if (request.status < 200 || request.status >= 300) {
reject(new Error(payload.error || "Upload failed"));
return;
}
setTotalProgress(100);
setFileProgress(files, 100);
resolve(payload);
});
request.addEventListener("error", () => reject(new Error("Network error during upload")));
request.addEventListener("abort", () => reject(new Error("Upload aborted")));
request.send(formData);
});
}
async function uploadResumable(fallbackUrl, formData, files) {
if (!window.fetch || typeof Blob === "undefined") {
return uploadWithProgress(fallbackUrl, formData, files);
}
updateStatus("Fingerprinting files...");
const fingerprints = await Promise.all(files.map((file) => fileFingerprint(file)));
const createPayload = {
files: files.map((file, index) => ({
name: file.name,
size: file.size,
contentType: file.type || "application/octet-stream",
fingerprint: fingerprints[index],
})),
expiresMinutes: parseInt(formData.get("expires_minutes") || "0", 10) || 0,
maxDownloads: parseInt(formData.get("max_downloads") || "0", 10) || 0,
password: formData.get("password") || "",
obfuscateMetadata: formData.get("obfuscate_metadata") === "on",
collectionId: formData.get("collection_id") || "",
};
const persistable = !createPayload.password;
let session = null;
if (persistable && resumeMode && recoveredDraft) {
session = await fetchResumableStatus(recoveredDraft.session.sessionId, recoveredDraft.session.resumeToken);
session.resumeToken = recoveredDraft.session.resumeToken;
} else if (persistable) {
session = await findResumableSession(createPayload);
}
if (session) {
validateResumeSelection(session, createPayload);
session = await addMissingResumableFiles(session, createPayload);
if (resumeMode && recoveredDraft && recoveredDraft.session.sessionId === session.sessionId) {
recoveredDraft.session = session;
}
if (persistable) {
saveResumableSession(session, createPayload);
}
}
if (!session || session.status !== "uploading") {
try {
session = await createResumableSession(createPayload);
} catch (error) {
if ((error.message || "").toLowerCase().includes("resumable uploads are disabled")) {
return uploadWithProgress(fallbackUrl, formData, files);
}
throw error;
}
if (persistable) {
saveResumableSession(session, createPayload);
}
}
const sessionFiles = files.map((file, index) => matchSessionFile(session, createPayload.files[index]));
if (sessionFiles.some((file) => !file)) {
throw new Error("Upload session could not match the selected files");
}
updateStatus("Uploading...");
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
const completedByFile = new Array(files.length).fill(0);
sessionFiles.forEach((sessionFile, index) => {
completedByFile[index] = uploadedBytesForSessionFile(sessionFile, session.chunkSize);
setSingleFileProgress(index, files[index], percentForBytes(completedByFile[index], files[index].size));
});
setTotalProgress(percentForBytes(completedByFile.reduce((sum, bytes) => sum + bytes, 0), totalBytes));
for (let fileIndex = 0; fileIndex < files.length; fileIndex++) {
const file = files[fileIndex];
const sessionFile = sessionFiles[fileIndex];
const uploaded = new Set(sessionFile.uploadedChunks || []);
for (let chunkIndex = 0; chunkIndex < sessionFile.chunkCount; chunkIndex++) {
if (uploaded.has(chunkIndex)) {
continue;
}
const start = chunkIndex * session.chunkSize;
const end = Math.min(file.size, start + session.chunkSize);
await uploadChunkWithRetry(session, sessionFile, chunkIndex, file.slice(start, end), (loaded) => {
const currentTotal = completedByFile.reduce((sum, bytes) => sum + bytes, 0) + loaded;
setTotalProgress(percentForBytes(currentTotal, totalBytes));
setSingleFileProgress(fileIndex, file, percentForBytes(completedByFile[fileIndex] + loaded, file.size));
updateStatus(`${percentForBytes(currentTotal, totalBytes)}%`);
});
completedByFile[fileIndex] += end - start;
uploaded.add(chunkIndex);
sessionFile.uploadedChunks = Array.from(uploaded).sort((a, b) => a - b);
if (persistable) {
saveResumableSession(session, createPayload);
}
}
setSingleFileProgress(fileIndex, file, 100);
}
updateStatus("Finalizing upload...");
const resultPayload = await completeResumableSession(session.sessionId, session.resumeToken);
const wasResumeMode = resumeMode;
if (persistable) {
removeResumableSession(session.sessionId);
}
if (resumeMode && recoveredDraft && recoveredDraft.session.sessionId === session.sessionId) {
resumeMode = false;
recoveredDraft = null;
}
setTotalProgress(100);
if (!wasResumeMode) {
setFileProgress(files, 100);
}
return resultPayload;
}
async function createResumableSession(payload) {
const response = await fetch("/api/v1/uploads/resumable", {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
return readUploadJSON(response, "Upload session could not be created");
}
async function fetchResumableStatus(sessionID, resumeToken) {
const response = await fetch(`/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}`, {
headers: resumableHeaders(resumeToken),
});
return readUploadJSON(response, "Upload session could not be resumed");
}
async function addResumableFiles(sessionID, resumeToken, files) {
const response = await fetch(`/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}/files`, {
method: "POST",
headers: {
...resumableHeaders(resumeToken),
"Accept": "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ files }),
});
return readUploadJSON(response, "Upload session files could not be added");
}
function uploadChunk(sessionID, resumeToken, fileID, chunkIndex, chunk, onProgress) {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("PUT", `/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}/files/${encodeURIComponent(fileID)}/chunks/${chunkIndex}`);
request.setRequestHeader("Accept", "application/json");
request.setRequestHeader("X-Warpbox-Resume-Token", resumeToken || "");
request.upload.addEventListener("progress", (event) => {
if (event.lengthComputable && onProgress) {
onProgress(event.loaded);
}
});
request.addEventListener("load", () => {
if (request.status < 200 || request.status >= 300) {
let payload = {};
try {
payload = JSON.parse(request.responseText || "{}");
} catch (error) {
payload = {};
}
reject(new Error(payload.error || "Chunk upload failed"));
return;
}
resolve();
});
request.addEventListener("error", () => reject(new Error("Network error during chunk upload")));
request.addEventListener("abort", () => reject(new Error("Chunk upload aborted")));
request.send(chunk);
});
}
async function uploadChunkWithRetry(session, sessionFile, chunkIndex, chunk, onProgress) {
const delays = [1000, 2000, 5000, 10000, 20000];
let lastError = null;
for (let attempt = 0; attempt <= delays.length; attempt++) {
try {
return await uploadChunk(session.sessionId, session.resumeToken, sessionFile.id, chunkIndex, chunk, onProgress);
} catch (error) {
lastError = error;
if (attempt >= delays.length) {
break;
}
const seconds = Math.round(delays[attempt] / 1000);
updateStatus(`Connection interrupted, retrying chunk ${chunkIndex + 1} in ${seconds}s`);
await wait(delays[attempt]);
}
}
throw lastError || new Error("Chunk upload failed");
}
async function completeResumableSession(sessionID, resumeToken) {
const response = await fetch(`/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}/complete`, {
method: "POST",
headers: resumableHeaders(resumeToken),
});
return readUploadJSON(response, "Upload could not be completed");
}
async function cancelResumableSession(sessionID, resumeToken) {
const response = await fetch(`/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}`, {
method: "DELETE",
headers: resumableHeaders(resumeToken),
});
if (!response.ok && response.status !== 404) {
await readUploadJSON(response, "Upload draft could not be deleted");
}
}
function resumableHeaders(resumeToken) {
return {
"Accept": "application/json",
"X-Warpbox-Resume-Token": resumeToken || "",
};
}
function wait(ms) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
async function readUploadJSON(response, fallback) {
let payload = {};
try {
payload = await response.json();
} catch (error) {
payload = {};
}
if (!response.ok) {
throw new Error(payload.error || fallback);
}
return payload;
}
async function findResumableSession(payload) {
const records = loadResumableSessions();
const optionKey = resumableOptionKey(payload);
const selectedKeys = new Set(payload.files.map((file) => resumableFileKey(file)));
for (const record of records) {
if (record.optionKey !== optionKey) {
continue;
}
if (!record.files || !record.files.some((file) => selectedKeys.has(resumableFileKey(file)))) {
continue;
}
const session = await fetchResumableStatus(record.sessionId, record.resumeToken).catch(() => null);
if (!session || session.status !== "uploading") {
removeResumableSession(record.sessionId);
continue;
}
session.resumeToken = record.resumeToken;
const sessionKeys = new Set(session.files.map((file) => resumableFileKey(file)));
const selectedContainsSessionFile = Array.from(sessionKeys).some((key) => selectedKeys.has(key));
if (selectedContainsSessionFile) {
return session;
}
}
return null;
}
async function addMissingResumableFiles(session, payload) {
const existing = new Set(session.files.map((file) => resumableFileKey(file)));
const missing = payload.files.filter((file) => !existing.has(resumableFileKey(file)));
if (missing.length === 0) {
return session;
}
const updated = await addResumableFiles(session.sessionId, session.resumeToken, missing);
updated.resumeToken = session.resumeToken;
return updated;
}
function validateResumeSelection(session, payload) {
if (!resumeMode || !recoveredDraft || session.sessionId !== recoveredDraft.session.sessionId) {
return;
}
const existingByNameSize = new Map();
(session.files || []).forEach((file) => {
existingByNameSize.set(`${file.name}:${file.size}`, resumableFileKey(file));
});
for (const file of payload.files || []) {
const expectedKey = existingByNameSize.get(`${file.name}:${file.size}`);
if (expectedKey && expectedKey !== resumableFileKey(file)) {
throw new Error(`"${file.name}" does not match the pending upload. Select the exact original file.`);
}
}
}
function matchSessionFile(session, file) {
const key = resumableFileKey(file);
return session.files.find((sessionFile) => resumableFileKey(sessionFile) === key) || null;
}
function resumableOptionKey(payload) {
return [
payload.expiresMinutes,
payload.maxDownloads,
payload.obfuscateMetadata ? "1" : "0",
payload.collectionId || "",
].join(":");
}
function resumableFileKey(file) {
return [file.name, file.size, file.fingerprint || ""].join(":");
}
function loadResumableSessions() {
try {
const value = localStorage.getItem(RESUMABLE_SESSIONS_KEY);
const records = value ? JSON.parse(value) : [];
return Array.isArray(records) ? records : [];
} catch (error) {
return [];
}
}
function saveResumableSession(session, payload) {
try {
const records = loadResumableSessions().filter((record) => record.sessionId !== session.sessionId);
records.push({
sessionId: session.sessionId,
resumeToken: session.resumeToken || "",
optionKey: resumableOptionKey(payload),
options: {
expiresMinutes: payload.expiresMinutes,
maxDownloads: payload.maxDownloads,
obfuscateMetadata: !!payload.obfuscateMetadata,
collectionId: payload.collectionId || "",
},
files: session.files.map((file) => ({
name: file.name,
size: file.size,
contentType: file.contentType || "application/octet-stream",
fingerprint: file.fingerprint || "",
uploadedChunks: file.uploadedChunks || [],
chunkCount: file.chunkCount || 0,
})),
updatedAt: new Date().toISOString(),
});
localStorage.setItem(RESUMABLE_SESSIONS_KEY, JSON.stringify(records.slice(-25)));
} catch (error) {
/* ignore persistence failures */
}
}
async function recoverResumableSessions() {
const records = loadResumableSessions()
.filter((record) => record.sessionId && record.resumeToken)
.sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime());
if (records.length === 0) {
return;
}
for (const record of records) {
const session = await fetchResumableStatus(record.sessionId, record.resumeToken).catch(() => null);
if (!session || session.status !== "uploading") {
removeResumableSession(record.sessionId);
continue;
}
session.resumeToken = record.resumeToken;
recoveredDraft = { session, record };
selectedFiles = [];
renderRecoveredQueue([{ session, record }]);
updateRecoveredSummary(session);
showRecoveryModal(recoveredDraft);
return;
}
}
function updateRecoveredSummary(session) {
updateStatus("Unfinished upload found. Choose how to continue.");
if (fileSummary) {
const totalFiles = (session.files || []).length;
const completedFiles = completedSessionFiles(session).length;
fileSummary.textContent = `Recovered ${totalFiles} pending file${totalFiles === 1 ? "" : "s"}; ${completedFiles} fully uploaded.`;
}
}
function removeResumableSession(sessionID) {
try {
const records = loadResumableSessions().filter((record) => record.sessionId !== sessionID);
localStorage.setItem(RESUMABLE_SESSIONS_KEY, JSON.stringify(records));
} catch (error) {
/* ignore persistence failures */
}
}
function completedSessionFiles(session) {
return (session.files || []).filter((file) => (file.uploadedChunks || []).length >= file.chunkCount);
}
function showRecoveryModal(draft) {
const old = document.querySelector(".upload-recovery-overlay");
if (old) {
old.remove();
}
const completeCount = completedSessionFiles(draft.session).length;
const totalCount = (draft.session.files || []).length;
const overlay = document.createElement("div");
overlay.className = "upload-recovery-overlay";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-labelledby", "upload-recovery-title");
const modal = document.createElement("div");
modal.className = "upload-recovery-modal card";
const content = document.createElement("div");
content.className = "card-content";
const title = document.createElement("h2");
title.id = "upload-recovery-title";
title.textContent = "Unfinished upload found";
const copy = document.createElement("p");
copy.textContent = `Warpbox found a private draft with ${totalCount} file${totalCount === 1 ? "" : "s"}. ${completeCount} file${completeCount === 1 ? " is" : "s are"} already fully uploaded.`;
const actions = document.createElement("div");
actions.className = "upload-recovery-actions";
const startOver = document.createElement("button");
startOver.type = "button";
startOver.className = "button button-danger";
startOver.textContent = "New Upload";
startOver.addEventListener("click", async () => {
startOver.disabled = true;
try {
await cancelRecoveredDraft();
overlay.remove();
} catch (error) {
startOver.disabled = false;
updateStatus(error.message || "Upload draft could not be deleted");
}
});
const resume = document.createElement("button");
resume.type = "button";
resume.className = "button button-primary";
resume.textContent = "Resume";
resume.addEventListener("click", () => {
resumeRecoveredDraft();
overlay.remove();
});
actions.append(startOver, resume);
content.append(title, copy, actions);
modal.append(content);
overlay.append(modal);
document.body.append(overlay);
}
async function cancelRecoveredDraft() {
if (!recoveredDraft) {
resetFreshUploadState();
return;
}
const draft = recoveredDraft;
updateStatus("Deleting unfinished upload...");
await cancelResumableSession(draft.session.sessionId, draft.session.resumeToken);
removeResumableSession(draft.session.sessionId);
resetFreshUploadState();
}
function resumeRecoveredDraft() {
if (!recoveredDraft) {
return;
}
resumeMode = true;
selectedFiles = [];
renderResumeQueue(recoveredDraft.session, selectedFiles);
updateSelectedState();
updateStatus("Drop or reselect missing files to continue. Extra files will be added to this upload.");
}
function resetFreshUploadState() {
selectedFiles = [];
resumeMode = false;
recoveredDraft = null;
fileInput.value = "";
result.hidden = true;
if (resultList) {
resultList.replaceChildren();
}
setTotalProgress(0);
updateStatus("");
updateSelectedState();
}
function uploadedBytesForSessionFile(file, chunkSize) {
return (file.uploadedChunks || []).reduce((sum, index) => {
const start = index * chunkSize;
const end = Math.min(file.size, start + chunkSize);
return sum + Math.max(0, end - start);
}, 0);
}
function renderRecoveredQueue(items) {
if (!uploadQueue) {
return;
}
const rows = [];
items.forEach(({ session }) => {
(session.files || []).forEach((file) => {
const uploadedBytes = uploadedBytesForSessionFile(file, session.chunkSize);
const complete = (file.uploadedChunks || []).length >= file.chunkCount;
rows.push({
name: file.name,
size: file.size,
uploadedBytes,
meta: complete
? `${window.Warpbox.formatBytes(file.size)} · uploaded`
: `${window.Warpbox.formatBytes(uploadedBytes)} of ${window.Warpbox.formatBytes(file.size)} · Drop/reselect this file to continue`,
progress: percentForBytes(uploadedBytes, file.size),
status: complete ? "complete" : "waiting",
readonly: true,
});
});
});
uploadQueue.hidden = rows.length === 0;
uploadQueue.replaceChildren();
rows.forEach((row) => uploadQueue.append(createFileRow(row)));
const totalBytes = rows.reduce((sum, row) => sum + (row.size || 0), 0);
if (totalBytes > 0) {
setTotalProgress(percentForBytes(rows.reduce((sum, row) => sum + (row.uploadedBytes || 0), 0), totalBytes));
} else if (rows.length > 0) {
const completed = rows.filter((row) => row.status === "complete").length;
setTotalProgress(percentForBytes(completed, rows.length));
}
}
function renderResumeQueue(session, localFiles) {
if (!uploadQueue) {
return;
}
const rows = [];
const localByNameSize = new Map();
(localFiles || []).forEach((file, index) => {
localByNameSize.set(`${file.name}:${file.size}`, { file, index });
});
const usedLocalIndexes = new Set();
(session.files || []).forEach((file) => {
const uploadedBytes = uploadedBytesForSessionFile(file, session.chunkSize);
const complete = (file.uploadedChunks || []).length >= file.chunkCount;
const localMatch = localByNameSize.get(`${file.name}:${file.size}`) || null;
if (localMatch) {
usedLocalIndexes.add(localMatch.index);
}
rows.push({
name: file.name,
size: file.size,
uploadedBytes,
meta: complete
? `${window.Warpbox.formatBytes(file.size)} · uploaded`
: localMatch
? `${window.Warpbox.formatBytes(uploadedBytes)} of ${window.Warpbox.formatBytes(file.size)} · ready to resume`
: `${window.Warpbox.formatBytes(uploadedBytes)} of ${window.Warpbox.formatBytes(file.size)} · waiting for local file`,
progress: percentForBytes(uploadedBytes, file.size),
status: complete ? "complete" : localMatch ? "queued" : "waiting",
readonly: !localMatch,
index: localMatch ? localMatch.index : undefined,
removable: Boolean(localMatch && !complete),
});
});
(localFiles || []).forEach((file, index) => {
if (usedLocalIndexes.has(index)) {
return;
}
rows.push({
name: file.name,
meta: `${window.Warpbox.formatBytes(file.size)} · new file`,
progress: 0,
status: "queued",
index,
removable: true,
});
});
uploadQueue.hidden = rows.length === 0;
uploadQueue.replaceChildren();
rows.forEach((row) => uploadQueue.append(createFileRow(row)));
}
function percentForBytes(bytes, total) {
if (!total) {
return 100;
}
return Math.max(0, Math.min(100, Math.round((bytes / total) * 100)));
}
function renderQueue(files, status) {
if (!uploadQueue) {
return;
}
uploadQueue.hidden = files.length === 0;
uploadQueue.replaceChildren();
files.forEach((file, index) => {
uploadQueue.append(createFileRow({
name: file.name,
meta: window.Warpbox.formatBytes(file.size),
progress: status === "queued" ? 0 : 100,
status,
index,
removable: status === "queued",
}));
});
}
function createFileRow(file) {
const row = document.createElement("div");
row.className = `result-item upload-file-row upload-file-${file.status || "queued"}`;
row.dataset.fileName = file.name;
if (typeof file.index === "number") {
row.dataset.fileIndex = file.index;
}
const body = document.createElement("span");
const name = document.createElement("strong");
name.className = "file-name";
name.textContent = file.name;
name.title = file.name;
const meta = document.createElement("code");
meta.textContent = file.meta;
body.append(name, meta);
const side = document.createElement("div");
side.className = "file-progress-side";
const percent = document.createElement("span");
percent.className = "file-progress-percent";
percent.textContent = `${file.progress}%`;
const bar = document.createElement("div");
bar.className = "progress file-progress";
const fill = document.createElement("span");
fill.style.transform = `scaleX(${file.progress / 100})`;
bar.append(fill);
side.append(percent, bar);
if (file.status === "waiting") {
const badge = document.createElement("small");
badge.className = "upload-file-state";
badge.textContent = "Needs local file";
side.append(badge);
}
if (file.removable) {
const remove = document.createElement("button");
remove.className = "upload-file-remove";
remove.type = "button";
remove.setAttribute("aria-label", `Remove ${file.name}`);
remove.textContent = "×";
remove.addEventListener("click", () => removeSelectedFile(file.index || 0));
side.append(remove);
}
row.append(body, side);
return row;
}
function uploadFormData() {
const formData = new FormData(form);
formData.delete("file");
selectedFiles.forEach((file) => {
formData.append("file", file, file.name);
});
return formData;
}
function fileIdentity(file) {
return [file.name, file.size, file.lastModified || 0].join(":");
}
async function fileFingerprint(file) {
if (!window.crypto || !window.crypto.subtle || !file.slice || typeof TextEncoder === "undefined") {
return fileIdentity(file);
}
const sampleSize = Math.min(file.size, 1024 * 1024);
const sample = await file.slice(0, sampleSize).arrayBuffer();
const metadata = new TextEncoder().encode([file.name, file.size, file.lastModified || 0, sampleSize].join(":"));
const combined = new Uint8Array(metadata.byteLength + sample.byteLength);
combined.set(metadata, 0);
combined.set(new Uint8Array(sample), metadata.byteLength);
const digest = await window.crypto.subtle.digest("SHA-256", combined);
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
function setTotalProgress(percent) {
if (totalProgressBar) {
totalProgressBar.style.transform = `scaleX(${Math.max(0, Math.min(100, percent)) / 100})`;
}
}
function setFileProgress(files, totalPercent) {
if (!uploadQueue) {
return;
}
const count = files.length || 1;
const completedFloat = (Math.max(0, Math.min(100, totalPercent)) / 100) * count;
uploadQueue.querySelectorAll(".upload-file-row").forEach((row, index) => {
const progress = Math.max(0, Math.min(100, Math.round((completedFloat - index) * 100)));
const percent = row.querySelector(".file-progress-percent");
const fill = row.querySelector(".file-progress span");
if (percent) {
percent.textContent = `${progress}%`;
}
if (fill) {
fill.style.transform = `scaleX(${progress / 100})`;
}
});
}
function setSingleFileProgress(index, file, progress) {
if (!uploadQueue) {
return;
}
const row = uploadQueue.querySelector(`.upload-file-row[data-file-index="${index}"]`);
if (!row) {
return;
}
const percent = row.querySelector(".file-progress-percent");
const fill = row.querySelector(".file-progress span");
const normalized = Math.max(0, Math.min(100, progress));
if (percent) {
percent.textContent = `${normalized}%`;
}
if (fill) {
fill.style.transform = `scaleX(${normalized / 100})`;
}
}
})();