Implement reusable upload platform

This commit is contained in:
“Naeel”
2026-09-05 08:51:32 +03:00
parent 82d9ca9136
commit c0b87880ae
35 changed files with 734 additions and 9 deletions
@@ -0,0 +1,27 @@
export function addFileWithDedup(state, file, cfg) {
const originalName = file.name;
const existing = state.fileMeta.get(originalName);
let name = originalName;
if (existing) {
if (existing.size === file.size) return false;
const dot = originalName.lastIndexOf('.');
const base = dot > 0 ? originalName.slice(0, dot) : originalName;
const extension = dot > 0 ? originalName.slice(dot) : '';
let suffix = 2;
while (state.fileMeta.has(`${base}_${suffix}${extension}`)) suffix += 1;
name = `${base}_${suffix}${extension}`;
}
const storedFile = new File([file], name, { lastModified: file.lastModified });
state.fileMeta.set(name, { size: storedFile.size });
const includedBytes = state.files.reduce(
(total, item) => total + (state.overNames.has(item.name) ? 0 : item.size), 0,
);
if (storedFile.size > cfg.maxFileBytes
|| includedBytes + storedFile.size > cfg.maxSessionBytes) {
state.overNames.add(name);
}
state.files.push(storedFile);
return true;
}
+9
View File
@@ -0,0 +1,9 @@
export function esc(value) {
return String(value).replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[character]));
}
+5
View File
@@ -0,0 +1,5 @@
export function fs(bytes) {
return bytes < 1024 ? `${bytes} B`
: bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB`
: `${(bytes / 1048576).toFixed(1)} MB`;
}
@@ -0,0 +1,44 @@
import { addFiles } from './on_files_change.js';
import { onFilesChange } from './on_files_change.js';
import { onFolderChange } from './on_folder_change.js';
import { render } from './render.js';
import { setStatus } from './set_status.js';
export function initUploadTable(cfg) {
const elements = {
fileInputEl: cfg.fileInputEl,
folderInputEl: cfg.folderInputEl,
tableBodyEl: cfg.tableBodyEl,
countEl: cfg.countEl,
uploadBtnEl: cfg.uploadBtnEl,
};
const state = { files: [], fileMeta: new Map(), overNames: new Set(), busy: false };
elements.fileInputEl.addEventListener('change', onFilesChange(state, cfg, elements));
elements.folderInputEl.addEventListener('change', onFolderChange(state, cfg, elements));
const api = {
pickFiles: () => elements.fileInputEl.click(),
pickFolder: () => elements.folderInputEl.click(),
addFiles: (files) => addFiles(state, cfg, files, elements),
getFiles: () => state.files
.filter((file) => !state.overNames.has(file.name))
.map((file) => ({ path: file.name, name: file.name, size: file.size, file })),
getOverNames: () => new Set(state.overNames),
setStatus: (path, html) => setStatus(path, html, state, elements),
render: () => render(state, elements),
clear: () => {
state.files = [];
state.fileMeta.clear();
state.overNames.clear();
render(state, elements);
},
setBusy: (busy) => {
state.busy = busy;
elements.fileInputEl.disabled = busy;
elements.folderInputEl.disabled = busy;
elements.uploadBtnEl.disabled = busy || api.getFiles().length === 0;
},
};
api.render();
return api;
}
+29
View File
@@ -0,0 +1,29 @@
import { listZipFiles } from '../zip/list_zip_files.js';
import { addFileWithDedup } from './add_file_with_dedup.js';
import { render } from './render.js';
export async function addFiles(state, cfg, files, elements) {
for (const file of Array.from(files)) {
if (!file.name.toLowerCase().endsWith('.zip')) {
addFileWithDedup(state, file, cfg);
continue;
}
try {
const extracted = await listZipFiles(file, cfg.allowedExt);
if (extracted.length) {
extracted.forEach((item) => addFileWithDedup(state, item, cfg));
} else {
addFileWithDedup(state, file, cfg);
}
} catch (error) {
addFileWithDedup(state, file, cfg);
}
}
render(state, elements);
}
export function onFilesChange(state, cfg, elements) {
return () => {
if (!state.busy) addFiles(state, cfg, elements.fileInputEl.files, elements);
};
}
+37
View File
@@ -0,0 +1,37 @@
import { listZipFiles } from '../zip/list_zip_files.js';
import { addFileWithDedup } from './add_file_with_dedup.js';
import { render } from './render.js';
export function onFolderChange(state, cfg, elements) {
return async () => {
if (state.busy) return;
for (const file of Array.from(elements.folderInputEl.files)) {
const parts = (file.webkitRelativePath || file.name).split('/');
const relativePath = parts.slice(1).join('/') || file.name;
const lowerPath = relativePath.toLowerCase();
const directory = relativePath.includes('/')
? relativePath.slice(0, relativePath.lastIndexOf('/')) : '';
if (lowerPath.endsWith('.zip')) {
try {
const extracted = await listZipFiles(file, cfg.allowedExt);
if (extracted.length) {
extracted.forEach((item) => addFileWithDedup(state, new File([item],
directory ? `${directory}/${item.name}` : item.name,
{ lastModified: item.lastModified }), cfg));
} else {
addFileWithDedup(state, new File([file], relativePath,
{ lastModified: file.lastModified }), cfg);
}
} catch (error) {
addFileWithDedup(state, new File([file], relativePath,
{ lastModified: file.lastModified }), cfg);
}
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
addFileWithDedup(state, new File([file], relativePath,
{ lastModified: file.lastModified }), cfg);
}
}
elements.folderInputEl.value = '';
render(state, elements);
};
}
+22
View File
@@ -0,0 +1,22 @@
import { esc } from './esc.js';
import { fs } from './fs.js';
export function render(state, elements) {
if (!state.files.length) {
elements.tableBodyEl.innerHTML = '<tr><td colspan="3">Нет выбранных файлов</td></tr>';
} else {
elements.tableBodyEl.innerHTML = state.files.map((file, index) => {
const over = state.overNames.has(file.name);
const status = over ? 'не учитывается (лимит)' : 'готов';
return `<tr id="row-${index}"><td>${esc(file.name)}</td>`
+ `<td>${fs(file.size)}</td><td id="st-${index}">${status}</td></tr>`;
}).join('');
}
const included = state.files.filter((file) => !state.overNames.has(file.name));
const overCount = state.files.length - included.length;
const size = included.reduce((total, file) => total + file.size, 0);
elements.countEl.textContent = `${included.length} учитываются`
+ (overCount ? ` + ${overCount} свыше лимита` : '')
+ ` · ${fs(size)}`;
elements.uploadBtnEl.disabled = included.length === 0;
}
+5
View File
@@ -0,0 +1,5 @@
export function setStatus(path, html, state, elements) {
const index = state.files.findIndex((file) => file.name === path);
const cell = elements.tableBodyEl.querySelector(`#st-${index}`);
if (cell) cell.innerHTML = html;
}
+19
View File
@@ -0,0 +1,19 @@
export function putToVm(file, url, options = {}) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', url);
xhr.timeout = options.timeoutMs || 300000;
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && options.onProgress) {
options.onProgress(Math.round(event.loaded / event.total * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`ВМ: HTTP ${xhr.status}`));
};
xhr.onerror = () => reject(new Error('Сеть (ВМ)'));
xhr.ontimeout = () => reject(new Error('Таймаут загрузки на ВМ'));
xhr.send(file);
});
}
+33
View File
@@ -0,0 +1,33 @@
import { putToVm } from './put_to_vm.js';
export async function uploadViaVM(files, vmUploadUrl, options = {}) {
const token = crypto.randomUUID();
const refs = [];
for (let index = 0; index < files.length; index += 1) {
const file = files[index];
const url = `${vmUploadUrl}${token}_${index}`;
options.onUploadStatus?.(`Загрузка на ВМ ${index + 1}/${files.length}: ${file.name}`);
try {
await putToVm(file, url, {
onProgress: (percent) => options.onStatus?.(file.name, `${percent}%`),
});
refs.push({ name: file.name, size: file.size, url });
} catch (error) {
options.onStatus?.(file.name, `Ошибка: ${error.message}`);
return { ok: false, error: `Ошибка загрузки на ВМ: ${error.message}` };
}
}
try {
const response = await fetch(`${options.apiBase || ''}/api/upload_refs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: options.session || '', files: refs }),
});
const data = await response.json();
if (!response.ok || !data.ok) throw new Error(data.error || `HTTP ${response.status}`);
return { ok: true, session: data.session, count: data.count || refs.length };
} catch (error) {
return { ok: false, error: `Ошибка передачи ссылок: ${error.message}` };
}
}
+32
View File
@@ -0,0 +1,32 @@
// Рекурсивно получить из ZIP только файлы с разрешёнными расширениями.
function extensionAllowed(name, allowedExt) {
const lowerName = name.toLowerCase();
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
}
function makeFile(data, name) {
return new File([data], name);
}
async function listEntries(data, prefix, allowedExt, depth) {
if (depth > 20) throw new Error('Слишком глубокая вложенность ZIP');
const entries = fflate.unzipSync(data);
const files = [];
for (const [entryName, entryData] of Object.entries(entries)) {
if (entryName.endsWith('/')) continue;
const path = prefix ? `${prefix}/${entryName}` : entryName;
if (entryName.toLowerCase().endsWith('.zip')) {
files.push(...await listEntries(entryData, path, allowedExt, depth + 1));
} else if (extensionAllowed(entryName, allowedExt)) {
files.push(makeFile(entryData, path));
}
}
return files;
}
export async function listZipFiles(file, allowedExt) {
const data = new Uint8Array(await file.arrayBuffer());
return listEntries(data, '', allowedExt, 0);
}