diff --git a/frontend/js/api.js b/frontend/js/api.js index a20f241..6a11666 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -91,9 +91,14 @@ export const api = { body: JSON.stringify({ parent_id: parentId || null }) }), deleteFolder: (id) => request(`/folders/${id}`, { method: 'DELETE' }), + // #212: accepts a single File or an array/FileList of Files. All go up in one request + // under the `files` field (the server also still accepts the legacy `file` field). + // onProgress reports aggregate percent across the whole batch. Resolves to the content + // object for a single file, or an array of them for a batch. uploadContent: async (file, onProgress, folderId) => { + const files = (file instanceof FileList || Array.isArray(file)) ? Array.from(file) : [file]; const formData = new FormData(); - formData.append('file', file); + for (const f of files) formData.append('files', f); if (folderId) formData.append('folder_id', folderId); return new Promise((resolve, reject) => { diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 601e986..b165a93 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -172,6 +172,7 @@ export default { 'content.upload_progress': 'Uploading...', 'content.upload_progress_named': 'Uploading {name}...', 'content.upload_progress_named_pct': 'Uploading {name}... {pct}%', + 'content.upload_progress_count': 'Uploading {count} files...', // Remote URL panel 'content.remote_url': 'Remote URL', 'content.remote_desc': 'Stream directly from a URL. Saves local bandwidth.', @@ -253,6 +254,7 @@ export default { 'content.toast.deleted': 'Content deleted', 'content.toast.updated': 'Content updated', 'content.toast.uploaded_named': '{name} uploaded successfully', + 'content.toast.uploaded_count': '{count} files uploaded successfully', 'content.toast.upload_failed_named': 'Failed to upload {name}: {error}', 'content.toast.folder_created_named': 'Folder "{name}" created', 'content.toast.folder_renamed': 'Folder renamed', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index f8fd7c6..cc96694 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -137,6 +137,7 @@ export default { 'content.upload_hint': 'Soporta MP4, WebM, AVI, MKV, JPEG, PNG, GIF, WebP', 'content.upload_progress': 'Subiendo...', 'content.upload_progress_named': 'Subiendo {name}...', + 'content.upload_progress_count': 'Subiendo {count} archivos...', 'content.upload_progress_named_pct': 'Subiendo {name}... {pct}%', 'content.remote_url': 'URL remota', 'content.remote_desc': 'Transmite directamente desde una URL. Ahorra ancho de banda local.', @@ -205,6 +206,7 @@ export default { 'content.toast.deleted': 'Contenido eliminado', 'content.toast.updated': 'Contenido actualizado', 'content.toast.uploaded_named': '{name} se subió correctamente', + 'content.toast.uploaded_count': '{count} archivos se subieron correctamente', 'content.toast.upload_failed_named': 'Error al subir {name}: {error}', 'content.toast.folder_created_named': 'Carpeta "{name}" creada', 'content.toast.folder_renamed': 'Carpeta renombrada', diff --git a/frontend/js/views/content-library.js b/frontend/js/views/content-library.js index 9e4ba3a..87f576b 100644 --- a/frontend/js/views/content-library.js +++ b/frontend/js/views/content-library.js @@ -233,24 +233,32 @@ const state = { }; async function handleFiles(files) { + const list = Array.from(files); + if (list.length === 0) return; const progress = document.getElementById('uploadProgress'); const progressFill = document.getElementById('uploadProgressFill'); const progressText = document.getElementById('uploadProgressText'); - for (const file of files) { - progress.style.display = 'block'; - progressFill.style.width = '0%'; - progressText.textContent = t('content.upload_progress_named', { name: file.name }); + // #212: send all selected files in a single request with aggregate progress, instead + // of one sequential XHR per file. + progress.style.display = 'block'; + progressFill.style.width = '0%'; + const label = list.length === 1 ? list[0].name : t('content.upload_progress_count', { count: list.length }); + progressText.textContent = label; - try { - await api.uploadContent(file, (pct) => { - progressFill.style.width = pct + '%'; - progressText.textContent = t('content.upload_progress_named_pct', { name: file.name, pct }); - }, state.currentFolderId); - showToast(t('content.toast.uploaded_named', { name: file.name }), 'success'); - } catch (err) { - showToast(t('content.toast.upload_failed_named', { name: file.name, error: err.message }), 'error'); - } + try { + await api.uploadContent(list, (pct) => { + progressFill.style.width = pct + '%'; + progressText.textContent = `${label} — ${pct}%`; + }, state.currentFolderId); + showToast( + list.length === 1 + ? t('content.toast.uploaded_named', { name: list[0].name }) + : t('content.toast.uploaded_count', { count: list.length }), + 'success' + ); + } catch (err) { + showToast(t('content.toast.upload_failed_named', { name: label, error: err.message }), 'error'); } progress.style.display = 'none'; diff --git a/server/routes/content.js b/server/routes/content.js index 8becd11..b78dde9 100644 --- a/server/routes/content.js +++ b/server/routes/content.js @@ -121,14 +121,27 @@ router.get('/folders', (req, res) => { }); // Upload content -router.post('/', checkStorageLimit, upload.single('file'), async (req, res) => { +// #212: multi-file upload. Accept the new `files` field (up to 20) and keep the legacy +// single `file` field so older clients / API callers / the replace flow are unaffected. +const uploadContentFiles = upload.fields([ + { name: 'files', maxCount: 20 }, + { name: 'file', maxCount: 1 }, +]); +router.post('/', checkStorageLimit, uploadContentFiles, async (req, res) => { try { if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before uploading.' }); - if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); + const files = [...((req.files && req.files.files) || []), ...((req.files && req.files.file) || [])]; + if (files.length === 0) return res.status(400).json({ error: 'No file uploaded' }); // #73: shared ingest - identical processing + insert for dashboard and agency uploads. - const content = await ingestUploadedFile({ file: req.file, userId: req.user.id, workspaceId: req.workspaceId, folderId: req.body.folder_id || null }); - res.status(201).json(content); + const folderId = req.body.folder_id || null; + const results = []; + for (const file of files) { + results.push(await ingestUploadedFile({ file, userId: req.user.id, workspaceId: req.workspaceId, folderId })); + } + // Backward-compatible shape: a single upload still returns the content object (what + // every existing caller reads); a multi-file upload returns the array of them. + res.status(201).json(results.length === 1 ? results[0] : results); } catch (err) { console.error('Upload error:', err); res.status(500).json({ error: 'Upload failed' }); diff --git a/server/test/content-multi-upload.test.js b/server/test/content-multi-upload.test.js new file mode 100644 index 0000000..7f3fbf7 --- /dev/null +++ b/server/test/content-multi-upload.test.js @@ -0,0 +1,107 @@ +'use strict'; + +// #212 multi-file upload. POST /api/content now accepts N files under the `files` field +// (one request instead of N), while still accepting the legacy single `file` field. +// Exercises the real router + multer over HTTP. + +const os = require('node:os'); +const path = require('node:path'); +const fs = require('node:fs'); +const crypto = require('node:crypto'); +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'st-multiup-')); +process.env.DATA_DIR = TMP; +process.env.SELF_HOSTED = 'true'; +process.env.NODE_ENV = 'test'; + +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'); +const express = require('express'); +const { db } = require('../db/database'); + +// A minimal valid PNG (8-byte signature + padding) — enough to pass the mime filter. +const PNG = Buffer.concat([Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), Buffer.alloc(32)]); + +let server, base; +const USER = 'u-multiup'; + +// Build a multipart/form-data body from field parts. Each file part: {field, filename, data}. +function multipart(fileParts) { + const boundary = '----st' + crypto.randomBytes(8).toString('hex'); + const chunks = []; + for (const f of fileParts) { + chunks.push(Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${f.field}"; filename="${f.filename}"\r\n` + + `Content-Type: image/png\r\n\r\n`)); + chunks.push(f.data); + chunks.push(Buffer.from('\r\n')); + } + chunks.push(Buffer.from(`--${boundary}--\r\n`)); + return { body: Buffer.concat(chunks), boundary }; +} + +function post(fileParts) { + const { body, boundary } = multipart(fileParts); + return new Promise((resolve, reject) => { + const req = http.request(base + '/', { + method: 'POST', + headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': body.length }, + }, (res) => { + let out = ''; + res.on('data', (c) => (out += c)); + res.on('end', () => resolve({ status: res.statusCode, json: out ? JSON.parse(out) : null })); + }); + req.on('error', reject); + req.end(body); + }); +} + +before(async () => { + // The fresh DATA_DIR has no uploads/content dir (the real app creates it at boot); + // multer's diskStorage would ENOENT without it. + fs.mkdirSync(require('../config').contentDir, { recursive: true }); + db.prepare("INSERT INTO users (id, email, password_hash, plan_id) VALUES (?, ?, 'x', 'free')").run(USER, USER + '@t.local'); + // content.workspace_id -> workspaces.id -> organizations.id are real FKs, so seed the chain. + db.prepare("INSERT INTO organizations (id, name, owner_user_id) VALUES ('org-multiup', 'Org', ?)").run(USER); + db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('ws-multiup', 'org-multiup', 'WS')").run(); + const app = express(); + app.use((req, _res, next) => { req.workspaceId = 'ws-multiup'; req.user = { id: USER, role: 'admin' }; next(); }); + app.use('/', require('../routes/content')); + server = http.createServer(app); + await new Promise((r) => server.listen(0, r)); + base = `http://127.0.0.1:${server.address().port}`; +}); + +after(() => new Promise((r) => server.close(r))); + +test('multiple files under `files` create one content row each and return an array', async () => { + const r = await post([ + { field: 'files', filename: 'a.png', data: PNG }, + { field: 'files', filename: 'b.png', data: PNG }, + { field: 'files', filename: 'c.png', data: PNG }, + ]); + assert.equal(r.status, 201); + assert.ok(Array.isArray(r.json), 'batch upload returns an array'); + assert.equal(r.json.length, 3); + const rows = db.prepare("SELECT filename FROM content WHERE workspace_id = 'ws-multiup'").all().map((x) => x.filename); + for (const n of ['a.png', 'b.png', 'c.png']) assert.ok(rows.includes(n), `${n} was ingested`); +}); + +test('legacy single `file` field still returns a single content object', async () => { + const r = await post([{ field: 'file', filename: 'legacy.png', data: PNG }]); + assert.equal(r.status, 201); + assert.ok(!Array.isArray(r.json), 'single legacy upload returns an object, not an array'); + assert.equal(r.json.filename, 'legacy.png'); +}); + +test('a single file under `files` also returns a single object (shape parity)', async () => { + const r = await post([{ field: 'files', filename: 'one.png', data: PNG }]); + assert.equal(r.status, 201); + assert.ok(!Array.isArray(r.json)); + assert.equal(r.json.filename, 'one.png'); +}); + +test('no files -> 400', async () => { + const r = await post([]); + assert.equal(r.status, 400); +});