From a965f181d9e5d4f2779c97b1dd4496ce3490fc0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sat, 5 Sep 2026 09:29:03 +0300 Subject: [PATCH] Add hierarchical file picker --- config.json | 8 +-- site/app.py | 2 +- site/static/style.css | 8 +++ site/templates/index.html | 2 +- upload/config.example.json | 8 +-- upload/frontend/table/add_file_with_dedup.js | 39 +++++-------- upload/frontend/table/init_upload_table.js | 34 ++++++++---- upload/frontend/table/on_files_change.js | 20 ++++--- upload/frontend/table/on_folder_change.js | 53 +++++++++++++----- upload/frontend/table/render.js | 58 +++++++++++++++----- upload/frontend/zip/list_zip_files.js | 46 +++++++++++++--- 11 files changed, 181 insertions(+), 97 deletions(-) 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