mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
feat(content): multi-file upload (#222)
Uploading N files fired N sequential XHRs (one POST per file). Select-many now goes up in a single request. - Server POST /api/content: upload.array-style `files` field (up to 20) via upload.fields, looping ingestUploadedFile per file. Keeps the legacy single `file` field so older clients / API callers are unaffected. Response shape is backward-compatible: a single file returns the content object (what every existing caller reads), a batch returns the array. - api.uploadContent: accepts a File, FileList, or array; appends all under `files`; aggregate upload progress; resolves to object (single) or array (batch). - content-library handleFiles: one batched request with aggregate progress and a "N files uploaded" toast instead of a per-file loop. - en/es i18n for the count-based progress/toast strings. checkStorageLimit is left as-is — it's a coarse pre-gate (blocks only when already at/over the limit), same as before; per-file aggregate sizing was a listed "consideration", not required, and is out of scope here. Test: content-multi-upload.test.js drives the real router+multer over HTTP — 3-file batch creates 3 rows and returns an array, legacy single `file` returns an object, single `files` returns an object, empty -> 400. Suite 545/545. Closes #212 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
792b105035
commit
5c6d508032
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
107
server/test/content-multi-upload.test.js
Normal file
107
server/test/content-multi-upload.test.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in a new issue