Add hierarchical file picker
This commit is contained in:
@@ -1,27 +1,16 @@
|
||||
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);
|
||||
export function addFileWithDedup(state, fileNode) {
|
||||
const accept = (node) => {
|
||||
if (node.kind === 'file') {
|
||||
const key = `${node.path}\u0000${node.file.size}`;
|
||||
if (state.fileMeta.has(key)) return null;
|
||||
state.fileMeta.set(key, node.id);
|
||||
return node;
|
||||
}
|
||||
node.children = node.children.map(accept).filter(Boolean);
|
||||
return node.children.length ? node : null;
|
||||
};
|
||||
const accepted = accept(fileNode);
|
||||
if (!accepted || (accepted.kind !== 'file' && !accepted.children.length)) return false;
|
||||
state.nodes.push(accepted);
|
||||
return true;
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
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';
|
||||
import { findNode, flattenFiles, render } from './render.js';
|
||||
|
||||
export function initUploadTable(cfg) {
|
||||
const elements = {
|
||||
@@ -11,24 +10,39 @@ export function initUploadTable(cfg) {
|
||||
tableBodyEl: cfg.tableBodyEl,
|
||||
countEl: cfg.countEl,
|
||||
};
|
||||
const state = { files: [], fileMeta: new Map(), overNames: new Set(), busy: false };
|
||||
const state = { nodes: [], fileMeta: new Map(), busy: false };
|
||||
elements.fileInputEl.addEventListener('change', onFilesChange(state, cfg, elements));
|
||||
elements.folderInputEl.addEventListener('change', onFolderChange(state, cfg, elements));
|
||||
elements.tableBodyEl.addEventListener('click', (event) => {
|
||||
const toggle = event.target.closest('[data-toggle]');
|
||||
if (toggle) {
|
||||
const found = findNode(state.nodes, toggle.dataset.toggle);
|
||||
if (found) found.node.expanded = !found.node.expanded;
|
||||
render(state, elements);
|
||||
return;
|
||||
}
|
||||
const remove = event.target.closest('[data-remove]');
|
||||
if (remove) {
|
||||
const found = findNode(state.nodes, remove.dataset.remove);
|
||||
if (found) found.nodes.splice(found.nodes.indexOf(found.node), 1);
|
||||
render(state, 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),
|
||||
getFiles: () => flattenFiles(state.nodes),
|
||||
remove: (id) => {
|
||||
const found = findNode(state.nodes, id);
|
||||
if (found) found.nodes.splice(found.nodes.indexOf(found.node), 1);
|
||||
render(state, elements);
|
||||
},
|
||||
render: () => render(state, elements),
|
||||
clear: () => {
|
||||
state.files = [];
|
||||
state.nodes = [];
|
||||
state.fileMeta.clear();
|
||||
state.overNames.clear();
|
||||
render(state, elements);
|
||||
},
|
||||
setBusy: (busy) => {
|
||||
|
||||
@@ -5,18 +5,22 @@ 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);
|
||||
if (!cfg.allowedExt.some((extension) => file.name.toLowerCase().endsWith(extension.toLowerCase()))) {
|
||||
continue;
|
||||
}
|
||||
addFileWithDedup(state, {
|
||||
id: crypto.randomUUID(), kind: 'file', name: file.name, path: file.name,
|
||||
file, children: [], expanded: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const extracted = await listZipFiles(file, cfg.allowedExt);
|
||||
if (extracted.length) {
|
||||
extracted.forEach((item) => addFileWithDedup(state, item, cfg));
|
||||
} else {
|
||||
addFileWithDedup(state, file, cfg);
|
||||
}
|
||||
addFileWithDedup(state, await listZipFiles(file, cfg.allowedExt));
|
||||
} catch (error) {
|
||||
addFileWithDedup(state, file, cfg);
|
||||
addFileWithDedup(state, {
|
||||
id: crypto.randomUUID(), kind: 'zip', name: file.name, path: file.name,
|
||||
file, children: [], expanded: true, error: 'Не удалось раскрыть ZIP',
|
||||
});
|
||||
}
|
||||
}
|
||||
render(state, elements);
|
||||
|
||||
@@ -2,35 +2,58 @@ import { listZipFiles } from '../zip/list_zip_files.js';
|
||||
import { addFileWithDedup } from './add_file_with_dedup.js';
|
||||
import { render } from './render.js';
|
||||
|
||||
function rebaseTree(root, prefix) {
|
||||
root.path = `${prefix}/${root.path}`;
|
||||
if (root.file) root.file = new File([root.file], root.path, { lastModified: root.file.lastModified });
|
||||
root.children.forEach((child) => rebaseTree(child, prefix));
|
||||
return root;
|
||||
}
|
||||
|
||||
export function onFolderChange(state, cfg, elements) {
|
||||
return async () => {
|
||||
if (state.busy) return;
|
||||
const roots = new Map();
|
||||
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('/')) : '';
|
||||
const rootName = parts[0] || file.name;
|
||||
if (!roots.has(rootName)) {
|
||||
roots.set(rootName, { id: crypto.randomUUID(), kind: 'folder', name: rootName,
|
||||
path: rootName, children: [], expanded: true });
|
||||
}
|
||||
const root = roots.get(rootName);
|
||||
const addToFolder = (node) => {
|
||||
let current = root;
|
||||
const nodeParts = node.path.split('/').slice(1);
|
||||
nodeParts.forEach((part, index) => {
|
||||
const last = index === nodeParts.length - 1;
|
||||
let child = current.children.find((item) => item.name === part);
|
||||
if (!child) {
|
||||
child = last ? node : { id: crypto.randomUUID(), kind: 'folder', name: part,
|
||||
path: `${rootName}/${nodeParts.slice(0, index + 1).join('/')}`,
|
||||
children: [], expanded: true };
|
||||
current.children.push(child);
|
||||
}
|
||||
current = child;
|
||||
});
|
||||
};
|
||||
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);
|
||||
}
|
||||
const zip = await listZipFiles(file, cfg.allowedExt);
|
||||
rebaseTree(zip, rootName);
|
||||
addToFolder(zip);
|
||||
} catch (error) {
|
||||
addFileWithDedup(state, new File([file], relativePath,
|
||||
{ lastModified: file.lastModified }), cfg);
|
||||
addToFolder({ id: crypto.randomUUID(), kind: 'zip', name: parts.at(-1),
|
||||
path: relativePath, file, children: [], expanded: true,
|
||||
error: 'Не удалось раскрыть ZIP' });
|
||||
}
|
||||
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
||||
addFileWithDedup(state, new File([file], relativePath,
|
||||
{ lastModified: file.lastModified }), cfg);
|
||||
addToFolder({ id: crypto.randomUUID(), kind: 'file', name: parts.at(-1),
|
||||
path: `${rootName}/${relativePath}`, file, children: [], expanded: true });
|
||||
}
|
||||
}
|
||||
roots.forEach((root) => addFileWithDedup(state, root));
|
||||
elements.folderInputEl.value = '';
|
||||
render(state, elements);
|
||||
};
|
||||
|
||||
@@ -1,21 +1,49 @@
|
||||
import { esc } from './esc.js';
|
||||
import { fs } from './fs.js';
|
||||
|
||||
function renderNode(node, depth) {
|
||||
const isGroup = node.kind !== 'file';
|
||||
const padding = depth * 4;
|
||||
const name = esc(node.name);
|
||||
const action = isGroup
|
||||
? `<button class="tree-name tree-group tree-${node.kind}" style="padding-left:${padding}ch" data-toggle="${node.id}" `
|
||||
+ `aria-expanded="${node.expanded}"><span class="tree-chevron">${node.expanded ? '▼' : '▶'}</span>${name}</button>`
|
||||
: `<span class="tree-name" style="padding-left:${padding}ch">${name}</span>`;
|
||||
const indent = isGroup ? '' : ` style="padding-left:${padding}ch"`;
|
||||
const remove = `<button class="remove-btn" type="button" data-remove="${node.id}" aria-label="Удалить ${name}">×</button>`;
|
||||
const row = `<tr class="tree-row tree-${node.kind}"><td${indent}>${action}</td>`
|
||||
+ `<td>${node.kind === 'file' ? fs(node.file.size) : ''}</td>`
|
||||
+ `<td>${node.error ? esc(node.error) : node.kind === 'file' ? 'готов' : ''}</td><td>${remove}</td></tr>`;
|
||||
if (!isGroup || !node.expanded) return row;
|
||||
return row + node.children.map((child) => renderNode(child, depth + 1)).join('');
|
||||
}
|
||||
|
||||
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('');
|
||||
elements.tableBodyEl.innerHTML = state.nodes.length
|
||||
? state.nodes.map((node) => renderNode(node, 0)).join('')
|
||||
: '<tr><td colspan="4">Нет выбранных файлов</td></tr>';
|
||||
const files = [];
|
||||
const visit = (node) => {
|
||||
if (node.kind === 'file') files.push(node);
|
||||
else node.children.forEach(visit);
|
||||
};
|
||||
state.nodes.forEach(visit);
|
||||
elements.countEl.textContent = `${files.length} файлов · ${fs(files.reduce((sum, node) => sum + node.file.size, 0))}`;
|
||||
}
|
||||
|
||||
export function findNode(nodes, id, parent = null) {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return { node, parent, nodes };
|
||||
const found = findNode(node.children || [], id, node);
|
||||
if (found) return found;
|
||||
}
|
||||
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)}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function flattenFiles(nodes, result = []) {
|
||||
nodes.forEach((node) => {
|
||||
if (node.kind === 'file') result.push({ path: node.path, name: node.name, size: node.file.size, file: node.file });
|
||||
else flattenFiles(node.children, result);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Рекурсивно получить из ZIP только файлы с разрешёнными расширениями.
|
||||
// Построить дерево ZIP с файлами только разрешённых расширений.
|
||||
|
||||
function extensionAllowed(name, allowedExt) {
|
||||
const lowerName = name.toLowerCase();
|
||||
@@ -9,24 +9,54 @@ function makeFile(data, name) {
|
||||
return new File([data], name);
|
||||
}
|
||||
|
||||
async function listEntries(data, prefix, allowedExt, depth) {
|
||||
function node(kind, name, path, children = [], file = null) {
|
||||
return { id: crypto.randomUUID(), kind, name, path, children, file, expanded: true };
|
||||
}
|
||||
|
||||
function rebaseTree(root, prefix) {
|
||||
root.path = `${prefix}/${root.path}`;
|
||||
if (root.file) root.file = new File([root.file], root.path, { lastModified: root.file.lastModified });
|
||||
root.children.forEach((child) => rebaseTree(child, prefix));
|
||||
return root;
|
||||
}
|
||||
|
||||
function addPath(root, parts, fileNode) {
|
||||
let current = root;
|
||||
parts.forEach((part, index) => {
|
||||
const last = index === parts.length - 1;
|
||||
let child = current.children.find((item) => item.name === part);
|
||||
if (!child) {
|
||||
child = last
|
||||
? fileNode
|
||||
: node('folder', part, `${current.path}/${part}`);
|
||||
current.children.push(child);
|
||||
}
|
||||
current = child;
|
||||
});
|
||||
}
|
||||
|
||||
async function listEntries(data, zipName, allowedExt, depth) {
|
||||
if (depth > 20) throw new Error('Слишком глубокая вложенность ZIP');
|
||||
const entries = fflate.unzipSync(data);
|
||||
const files = [];
|
||||
const root = node('zip', zipName, zipName);
|
||||
|
||||
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));
|
||||
const nested = await listEntries(entryData, entryName, allowedExt, depth + 1);
|
||||
rebaseTree(nested, zipName);
|
||||
nested.name = entryName.split('/').pop();
|
||||
addPath(root, entryName.split('/'), nested);
|
||||
} else if (extensionAllowed(entryName, allowedExt)) {
|
||||
files.push(makeFile(entryData, path));
|
||||
const path = `${zipName}/${entryName}`;
|
||||
const file = makeFile(entryData, path);
|
||||
addPath(root, entryName.split('/'), node('file', entryName.split('/').pop(), path, [], file));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
return root;
|
||||
}
|
||||
|
||||
export async function listZipFiles(file, allowedExt) {
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
return listEntries(data, '', allowedExt, 0);
|
||||
return listEntries(data, file.name, allowedExt, 0);
|
||||
}
|
||||
Reference in New Issue
Block a user