/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/web/assets/js/upload.js * Purpose : Chunked resumable upload manager + Google-Drive-style panel * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { post, api } from './api.js'; import { h, fmtSize, toast } from './ui.js'; import { UI } from './icons.js'; const CONCURRENT_FILES = 3; /** * Walk dropped DataTransfer items (files AND directory trees). * @returns {Promise>} */ export async function collectDropped(dataTransfer) { const out = []; const entries = [...dataTransfer.items] .filter((i) => i.kind === 'file') .map((i) => i.webkitGetAsEntry?.() ?? null); if (entries.every((e) => e === null)) { for (const file of dataTransfer.files) out.push({ file, relPath: file.name }); return out; } const walk = async (entry, prefix) => { if (!entry) return; if (entry.isFile) { const file = await new Promise((res, rej) => entry.file(res, rej)); out.push({ file, relPath: prefix + entry.name }); } else if (entry.isDirectory) { const reader = entry.createReader(); // readEntries returns batches of ≤100; loop until empty. for (;;) { const batch = await new Promise((res, rej) => reader.readEntries(res, rej)); if (!batch.length) break; for (const child of batch) await walk(child, `${prefix}${entry.name}/`); } } }; for (const entry of entries) await walk(entry, ''); return out; } export class UploadManager { /** * @param {{onFinished?: (parentId: number) => void, * resolveConflict?: (item, existing) => Promise<'keep-both'|'replace'|'skip'>}} hooks */ constructor(hooks = {}) { this.hooks = hooks; this.items = []; this.active = 0; this.panel = document.getElementById('uploadPanel'); } /** Enqueue files targeted at a folder. items: {file, relPath} */ add(files, parentId) { for (const { file, relPath } of files) { this.items.push({ file, relPath: relPath || file.name, parentId, state: 'queued', // queued | uploading | done | error | cancelled sent: 0, uploadId: null, speed: 0, el: null, cancelled: false, }); } this.renderPanel(); this.pump(); } pump() { while (this.active < CONCURRENT_FILES) { const next = this.items.find((i) => i.state === 'queued'); if (!next) break; this.active += 1; this.uploadOne(next).finally(() => { this.active -= 1; this.pump(); if (!this.items.some((i) => i.state === 'queued' || i.state === 'uploading')) { const target = this.items.at(-1)?.parentId; const okCount = this.items.filter((i) => i.state === 'done').length; if (okCount) this.hooks.onFinished?.(target); } }); } } async uploadOne(item) { item.state = 'uploading'; this.updateItem(item); try { const init = await post('/api/v1/upload/init', { parentId: item.parentId, name: item.relPath.split('/').pop(), path: item.relPath.includes('/') ? item.relPath : undefined, size: item.file.size, }); item.uploadId = init.uploadId; // Same-name file already there? Ask: keep both / replace (new version) / skip. let conflict = 'keep-both'; if (init.conflictsWith && this.hooks.resolveConflict) { conflict = await this.hooks.resolveConflict(item, init.conflictsWith) ?? 'keep-both'; if (conflict === 'skip') { await api(`/api/v1/upload/${item.uploadId}`, { method: 'DELETE' }).catch(() => {}); item.state = 'skipped'; this.updateItem(item); this.updateHead(); return; } } const { chunkSize, nChunks } = init; let have = new Set(init.have); const startedAt = Date.now(); for (let n = 0; n < nChunks; n += 1) { if (item.cancelled) throw new Error('cancelled'); if (have.has(n)) { item.sent += chunkSize; continue; } const blob = item.file.slice(n * chunkSize, Math.min((n + 1) * chunkSize, item.file.size)); let attempt = 0; for (;;) { try { const res = await fetch(`/api/v1/upload/${item.uploadId}/chunk/${n}`, { method: 'PUT', headers: { 'content-type': 'application/octet-stream', 'x-spbdrive-csrf': '1' }, body: blob, credentials: 'same-origin', }); if (!res.ok) throw new Error(`chunk ${n}: HTTP ${res.status}`); break; } catch (err) { attempt += 1; if (item.cancelled || attempt > 3) throw err; await new Promise((r) => setTimeout(r, 1000 * attempt)); // Ask the server what it already has (resume support). const status = await api(`/api/v1/upload/${item.uploadId}`); have = new Set(status.have); if (have.has(n)) break; } } item.sent = Math.min((n + 1) * chunkSize, item.file.size); item.speed = item.sent / Math.max((Date.now() - startedAt) / 1000, 0.1); this.updateItem(item); } await post(`/api/v1/upload/${item.uploadId}/complete`, { conflict, mime: item.file.type || undefined, }); item.state = 'done'; item.sent = item.file.size; } catch (err) { if (item.cancelled) { item.state = 'cancelled'; if (item.uploadId) api(`/api/v1/upload/${item.uploadId}`, { method: 'DELETE' }).catch(() => {}); } else { item.state = 'error'; item.error = err.message; } } this.updateItem(item); this.updateHead(); } cancel(item) { item.cancelled = true; if (item.state === 'queued') { item.state = 'cancelled'; this.updateItem(item); } } retry(item) { if (item.state !== 'error') return; item.state = 'queued'; item.cancelled = false; item.sent = 0; this.updateItem(item); this.pump(); } // ── Panel UI ──────────────────────────────────────────────────────── renderPanel() { if (!this.panel) return; if (!this.panel.querySelector('.upload-head')) { this.panel.innerHTML = ''; this.head = h('div.upload-head', {}, h('span', {}, 'Uploads'), this.agg = h('span.agg'), h('button.btn.icon.ghost', { title: 'Minimize', onclick: () => this.panel.classList.toggle('min'), }, h('span', { html: UI.chevron, style: { display: 'contents' } })), h('button.btn.icon.ghost', { title: 'Close', onclick: () => { this.panel.classList.remove('open'); this.items = this.items.filter((i) => i.state === 'uploading' || i.state === 'queued'); }, }, h('span', { html: UI.close, style: { display: 'contents' } })), ); this.list = h('div.upload-list'); this.panel.append(this.head, this.list); } this.panel.classList.add('open'); for (const item of this.items) { if (!item.el) { item.el = h('div.upload-item', {}, h('div.fname', {}, h('span.n', {}, item.relPath), h('span.s'), h('div.prog', {}, h('i')), ), h('div.act', {}, h('button.btn.icon.ghost', { title: 'Retry', style: { display: 'none' }, onclick: () => this.retry(item), }, h('span', { html: UI.restore, style: { display: 'contents' } })), h('button.btn.icon.ghost', { title: 'Cancel', onclick: () => this.cancel(item), }, h('span', { html: UI.close, style: { display: 'contents' } })), ), ); this.list.prepend(item.el); } this.updateItem(item); } this.updateHead(); } updateItem(item) { if (!item.el) return; const pct = item.file.size ? Math.min(100, (item.sent / item.file.size) * 100) : 100; item.el.className = `upload-item${item.state === 'done' ? ' done' : ''}${item.state === 'error' ? ' err' : ''}`; item.el.querySelector('.prog i').style.width = `${item.state === 'done' ? 100 : pct}%`; const status = { queued: 'Waiting…', uploading: `${fmtSize(item.sent)} / ${fmtSize(item.file.size)} · ${fmtSize(item.speed)}/s`, done: `Done · ${fmtSize(item.file.size)}`, error: `Failed — ${item.error ?? 'error'}`, cancelled: 'Cancelled', skipped: 'Skipped (already exists)', }[item.state]; item.el.querySelector('.s').textContent = status; item.el.querySelector('[title="Retry"]').style.display = item.state === 'error' ? '' : 'none'; item.el.querySelector('[title="Cancel"]').style.display = item.state === 'uploading' || item.state === 'queued' ? '' : 'none'; } updateHead() { if (!this.agg) return; const total = this.items.length; const done = this.items.filter((i) => i.state === 'done').length; const failed = this.items.filter((i) => i.state === 'error').length; const activeBytes = this.items.reduce((s, i) => s + i.sent, 0); const allBytes = this.items.reduce((s, i) => s + i.file.size, 0); this.agg.textContent = failed ? `${done}/${total} · ${failed} failed` : done === total ? `${total} done` : `${done}/${total} · ${allBytes ? Math.round((activeBytes / allBytes) * 100) : 0}%`; } } /** Wire paste-to-upload: screenshots land as pasted-YYYYMMDD-HHmmss.png. */ export function bindPasteUpload(manager, getCurrentFolder) { document.addEventListener('paste', (e) => { if (['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) return; const files = [...(e.clipboardData?.items ?? [])] .filter((i) => i.kind === 'file') .map((i) => i.getAsFile()) .filter(Boolean); if (!files.length) return; e.preventDefault(); const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 15).replace(/^(\d{8})/, '$1-'); const items = files.map((file, i) => ({ file, relPath: file.name && file.name !== 'image.png' ? file.name : `pasted-${stamp}${files.length > 1 ? `-${i + 1}` : ''}.png`, })); manager.add(items, getCurrentFolder()); toast(`Uploading ${items.length} pasted file${items.length > 1 ? 's' : ''}…`); }); }