diff --git a/config.json b/config.json
index 9dd3def..831d2db 100644
--- a/config.json
+++ b/config.json
@@ -1,9 +1,3 @@
{
- "vmUploadUrl": "https://contracts.kube5s.ru/drhider-upload/",
- "allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"],
- "maxFileBytes": 52428800,
- "maxSessionBytes": 524288000,
- "apiPrefix": "/api",
- "pullRetries": 3,
- "pullRetryDelay": 2
+ "allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"]
}
\ No newline at end of file
diff --git a/site/app.py b/site/app.py
index 4476248..3aa18fd 100644
--- a/site/app.py
+++ b/site/app.py
@@ -9,7 +9,7 @@ with (ROOT / "config.json").open(encoding="utf-8") as config_file:
CONFIG = json.load(config_file)
-VERSION = "0.1.3"
+VERSION = "0.1.4"
app = Flask(__name__, template_folder="templates", static_folder="static")
diff --git a/site/static/style.css b/site/static/style.css
index c4c0d51..ca050f2 100644
--- a/site/static/style.css
+++ b/site/static/style.css
@@ -26,6 +26,14 @@ table { width: 100%; border-collapse: collapse; font: 14px/1.4 sans-serif; }
th, td { padding: 14px 10px; border-bottom: 1px solid #cbd5cc; text-align: left; }
th { color: #56745f; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
td:nth-child(2) { width: 140px; color: #68766c; }
+.tree-name { display: inline-flex; align-items: center; gap: 8px; min-width: 240px; padding-top: 0; padding-bottom: 0; border: 0; background: transparent; color: #17211b; font: inherit; text-align: left; }
+.tree-group { padding-left: 0; cursor: pointer; }
+.tree-folder { color: #1f5a3b; }
+.tree-zip { color: #8a5a20; }
+.tree-chevron { display: inline-block; width: 12px; color: #78917d; font-size: 10px; }
+.remove-btn { padding: 2px 7px; color: #8c5148; background: transparent; font: 20px/1 sans-serif; }
+.remove-btn:hover { color: #b32f22; }
+.tree-row td:first-child { white-space: nowrap; }
.count { color: #5d6a61; font: 13px sans-serif; }
.result { margin-top: 44px; padding-top: 18px; border-top: 2px solid #1f5a3b; }
.result h2 { font-size: 26px; font-weight: 400; }
diff --git a/site/templates/index.html b/site/templates/index.html
index 65a57c0..231029d 100644
--- a/site/templates/index.html
+++ b/site/templates/index.html
@@ -23,7 +23,7 @@
- | Путь | Размер | Статус |
+ | Путь | Размер | Статус | |
diff --git a/upload/config.example.json b/upload/config.example.json
index 5e14973..831d2db 100644
--- a/upload/config.example.json
+++ b/upload/config.example.json
@@ -1,9 +1,3 @@
{
- "vmUploadUrl": "https://example.invalid/upload/",
- "allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"],
- "maxFileBytes": 52428800,
- "maxSessionBytes": 524288000,
- "apiPrefix": "/api",
- "pullRetries": 3,
- "pullRetryDelay": 2
+ "allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"]
}
\ No newline at end of file
diff --git a/upload/frontend/table/add_file_with_dedup.js b/upload/frontend/table/add_file_with_dedup.js
index 75c6331..35992f3 100644
--- a/upload/frontend/table/add_file_with_dedup.js
+++ b/upload/frontend/table/add_file_with_dedup.js
@@ -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;
}
\ No newline at end of file
diff --git a/upload/frontend/table/init_upload_table.js b/upload/frontend/table/init_upload_table.js
index d84653f..f23c1e7 100644
--- a/upload/frontend/table/init_upload_table.js
+++ b/upload/frontend/table/init_upload_table.js
@@ -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) => {
diff --git a/upload/frontend/table/on_files_change.js b/upload/frontend/table/on_files_change.js
index 5918bd8..d99f7f4 100644
--- a/upload/frontend/table/on_files_change.js
+++ b/upload/frontend/table/on_files_change.js
@@ -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);
diff --git a/upload/frontend/table/on_folder_change.js b/upload/frontend/table/on_folder_change.js
index 26f446f..98cfd48 100644
--- a/upload/frontend/table/on_folder_change.js
+++ b/upload/frontend/table/on_folder_change.js
@@ -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);
};
diff --git a/upload/frontend/table/render.js b/upload/frontend/table/render.js
index 63fa142..b8ca129 100644
--- a/upload/frontend/table/render.js
+++ b/upload/frontend/table/render.js
@@ -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
+ ? ``
+ : `${name}`;
+ const indent = isGroup ? '' : ` style="padding-left:${padding}ch"`;
+ const remove = ``;
+ const row = `| ${action} | `
+ + `${node.kind === 'file' ? fs(node.file.size) : ''} | `
+ + `${node.error ? esc(node.error) : node.kind === 'file' ? 'готов' : ''} | ${remove} |
`;
+ 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 = '| Нет выбранных файлов |
';
- } else {
- elements.tableBodyEl.innerHTML = state.files.map((file, index) => {
- const over = state.overNames.has(file.name);
- const status = over ? 'не учитывается (лимит)' : 'готов';
- return `| ${esc(file.name)} | `
- + `${fs(file.size)} | ${status} |
`;
- }).join('');
+ elements.tableBodyEl.innerHTML = state.nodes.length
+ ? state.nodes.map((node) => renderNode(node, 0)).join('')
+ : '| Нет выбранных файлов |
';
+ 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;
}
\ No newline at end of file
diff --git a/upload/frontend/zip/list_zip_files.js b/upload/frontend/zip/list_zip_files.js
index 4da6d3f..89a29b0 100644
--- a/upload/frontend/zip/list_zip_files.js
+++ b/upload/frontend/zip/list_zip_files.js
@@ -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);
}
\ No newline at end of file