Initial deployable Flask application
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Добавляет узел файла или группы в состояние picker с рекурсивной дедупликацией.
|
||||
*
|
||||
* @param {{nodes: Array, fileKeys: Set<string>}} state Внутреннее состояние таблицы.
|
||||
* @param {{kind: string, path: string, file?: File, children?: Array}} fileNode
|
||||
* Корневой узел файла, папки или ZIP-архива.
|
||||
* @returns {boolean} True, если в состояние добавлен хотя бы один узел.
|
||||
*
|
||||
* Ключ дедупликации состоит из полного пути и размера файла. Это позволяет
|
||||
* повторно выбрать файл после удаления и одновременно не скрывает файл с тем
|
||||
* же путём, но другим размером.
|
||||
*/
|
||||
export function addFileWithDedup(state, fileNode) {
|
||||
// Удаляет дубли и пустые группы снизу вверх, сохраняя исходные File objects.
|
||||
const accept = (node) => {
|
||||
if (node.kind === 'file') {
|
||||
// NUL-разделитель исключает неоднозначность при склейке пути и размера.
|
||||
const key = `${node.path}\u0000${node.file.size}`;
|
||||
if (state.fileKeys.has(key)) return null;
|
||||
state.fileKeys.add(key);
|
||||
return node;
|
||||
}
|
||||
// Группа нужна только пока после фильтрации в ней остался хотя бы один leaf.
|
||||
node.children = node.children.map(accept).filter(Boolean);
|
||||
return node.children.length ? node : null;
|
||||
};
|
||||
const accepted = accept(fileNode);
|
||||
// Пустой ZIP/каталог не должен появляться в таблице как пустая строка.
|
||||
if (!accepted || (accepted.kind !== 'file' && !accepted.children.length)) return false;
|
||||
if (accepted.kind !== 'file') {
|
||||
const existing = state.nodes.find((node) => node.kind !== 'file' && node.path === accepted.path);
|
||||
if (existing) {
|
||||
mergeChildren(existing, accepted);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
state.nodes.push(accepted);
|
||||
return true;
|
||||
}
|
||||
|
||||
function mergeChildren(target, incoming) {
|
||||
incoming.children.forEach((child) => {
|
||||
if (child.kind === 'file') {
|
||||
const duplicate = target.children.some((existing) => existing.kind === 'file'
|
||||
&& existing.path === child.path && existing.file.size === child.file.size);
|
||||
if (!duplicate) target.children.push(child);
|
||||
return;
|
||||
}
|
||||
const existing = target.children.find((candidate) => candidate.kind !== 'file'
|
||||
&& candidate.path === child.path);
|
||||
if (existing) mergeChildren(existing, child);
|
||||
else target.children.push(child);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Экранирует текст перед вставкой в HTML-шаблон, собираемый через innerHTML.
|
||||
*
|
||||
* @param {*} value Имя файла, путь или сообщение статуса.
|
||||
* @returns {string} Строка с заменёнными HTML-значимыми символами.
|
||||
*
|
||||
* Экранируются и кавычки, потому что значение может попасть не только в текст
|
||||
* кнопки, но и в HTML-атрибут вроде data-path или aria-label.
|
||||
*/
|
||||
export function esc(value) {
|
||||
return String(value).replace(/[&<>"']/g, (character) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[character]));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Преобразует размер файла из байтов в короткую строку для таблицы.
|
||||
*
|
||||
* @param {number} bytes Размер в байтах.
|
||||
* @returns {string} Значение в B, KB или MB с одним знаком после запятой.
|
||||
*/
|
||||
export function fs(bytes) {
|
||||
return bytes < 1024 ? `${bytes} B`
|
||||
: bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB`
|
||||
: `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { listZipFiles } from '../zip/list_zip_files.js';
|
||||
import { addFileWithDedup } from './add_file_with_dedup.js';
|
||||
import { render } from './render.js';
|
||||
|
||||
/**
|
||||
* Обрабатывает список файлов из обычного file input.
|
||||
*
|
||||
* @param {{fileKeys: Set<string>, nodes: Array, busy: boolean}} state Состояние picker.
|
||||
* @param {{allowedExt: string[]}} cfg Конфигурация разрешённых расширений.
|
||||
* @param {FileList|File[]} files Выбранные browser File objects.
|
||||
* @param {{tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-элементы вывода.
|
||||
* @returns {Promise<void>} Завершается после разбора всех ZIP и перерисовки таблицы.
|
||||
*
|
||||
* Обычные документы добавляются сразу. ZIP читаются асинхронно в браузере;
|
||||
* ошибка одного архива не отменяет обработку остальных выбранных файлов.
|
||||
*/
|
||||
export async function addFiles(state, cfg, files, elements) {
|
||||
for (const file of Array.from(files)) {
|
||||
if (!file.name.toLowerCase().endsWith('.zip')) {
|
||||
// Фильтр повторяет accept в HTML, потому что accept не является защитой API.
|
||||
if (!cfg.allowedExt.some((extension) => file.name.toLowerCase().endsWith(extension.toLowerCase()))) {
|
||||
continue;
|
||||
}
|
||||
// Обычный файл получает имя как путь: у file input нет относительного пути.
|
||||
addFileWithDedup(state, {
|
||||
id: crypto.randomUUID(), kind: 'file', name: file.name, path: file.name,
|
||||
file, children: [], expanded: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// listZipFiles возвращает дерево только с разрешёнными leaf-файлами.
|
||||
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
||||
if (zipTree) addFileWithDedup(state, zipTree);
|
||||
} catch (error) {
|
||||
// Битый или небезопасный ZIP пропускается, чтобы не блокировать picker.
|
||||
if (typeof cfg.onError === 'function') {
|
||||
cfg.onError(error, file);
|
||||
} else if (elements.statusEl) {
|
||||
elements.statusEl.textContent = `Ошибка чтения архива ${file.name}: ${error.message}`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Единая перерисовка после всей пачки предотвращает промежуточные состояния UI.
|
||||
render(state, elements, cfg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт обработчик change для обычного выбора файлов.
|
||||
*
|
||||
* @param {object} state Состояние picker, включая флаг busy.
|
||||
* @param {object} cfg Конфигурация picker.
|
||||
* @param {object} elements DOM-элементы picker.
|
||||
* @returns {Function} Async-обработчик для addEventListener('change', ...).
|
||||
*
|
||||
* busy устанавливается синхронно до первого await. Поэтому второе быстрое
|
||||
* событие выбора не запускает параллельный разбор и не меняет дерево конкурирующе.
|
||||
*/
|
||||
export function onFilesChange(state, cfg, elements) {
|
||||
return async () => {
|
||||
if (state.busy) return;
|
||||
state.busy = true;
|
||||
try {
|
||||
await addFiles(state, cfg, elements.fileInputEl.files, elements);
|
||||
} finally {
|
||||
// Даже исключение вне внутреннего ZIP-catch не оставляет picker заблокированным.
|
||||
elements.fileInputEl.value = '';
|
||||
state.busy = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { listZipFiles } from '../zip/list_zip_files.js';
|
||||
import { addFileWithDedup } from './add_file_with_dedup.js';
|
||||
import { rebaseTree } from './rebase_tree.js';
|
||||
import { render } from './render.js';
|
||||
|
||||
/**
|
||||
* Создаёт async-обработчик выбора каталога через webkitdirectory.
|
||||
*
|
||||
* @param {{nodes: Array, fileKeys: Set<string>, busy: boolean}} state Состояние picker.
|
||||
* @param {{allowedExt: string[]}} cfg Разрешённые расширения.
|
||||
* @param {{folderInputEl: HTMLInputElement, tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-элементы.
|
||||
* @returns {Function} Async-обработчик события change.
|
||||
*
|
||||
* Браузер отдаёт плоский FileList с webkitRelativePath. Обработчик группирует
|
||||
* его по первой части пути, создаёт промежуточные папки и затем добавляет каждый
|
||||
* корень через общую дедупликацию. ZIP внутри каталога раскрывается тем же кодом,
|
||||
* что и ZIP из обычного file input.
|
||||
*/
|
||||
export function onFolderChange(state, cfg, elements) {
|
||||
return async () => {
|
||||
if (state.busy) return;
|
||||
state.busy = true;
|
||||
try {
|
||||
const roots = new Map();
|
||||
for (const file of Array.from(elements.folderInputEl.files)) {
|
||||
// Для webkitdirectory путь начинается с выбранной корневой папки.
|
||||
const parts = (file.webkitRelativePath || file.name).split('/');
|
||||
const relativePath = parts.slice(1).join('/') || file.name;
|
||||
const lowerPath = relativePath.toLowerCase();
|
||||
const rootName = parts[0] || file.name;
|
||||
if (!roots.has(rootName)) {
|
||||
// Один root на выбранный каталог позволяет сохранить дерево целиком.
|
||||
roots.set(rootName, { id: crypto.randomUUID(), kind: 'folder', name: rootName,
|
||||
path: rootName, children: [], expanded: false });
|
||||
}
|
||||
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: false };
|
||||
current.children.push(child);
|
||||
}
|
||||
current = child;
|
||||
});
|
||||
};
|
||||
if (lowerPath.endsWith('.zip')) {
|
||||
try {
|
||||
// ZIP rebased на имя выбранной папки перед вставкой в общий root.
|
||||
const zip = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
||||
if (zip) {
|
||||
rebaseTree(zip, rootName);
|
||||
addToFolder(zip);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ошибка одного ZIP не должна терять остальные файлы каталога.
|
||||
if (typeof cfg.onError === 'function') {
|
||||
cfg.onError(error, file);
|
||||
} else if (elements.statusEl) {
|
||||
elements.statusEl.textContent = `Ошибка чтения архива ${file.name}: ${error.message}`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
||||
addToFolder({ id: crypto.randomUUID(), kind: 'file', name: parts.at(-1),
|
||||
path: `${rootName}/${relativePath}`, file, children: [], expanded: true });
|
||||
}
|
||||
}
|
||||
// Дедупликация выполняется после сборки каждого корня каталога.
|
||||
roots.forEach((root) => addFileWithDedup(state, root));
|
||||
// Сброс value позволяет выбрать тот же каталог повторно.
|
||||
elements.folderInputEl.value = '';
|
||||
render(state, elements, cfg);
|
||||
} finally {
|
||||
state.busy = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Добавляет префикс корневой папки к каждому пути дерева.
|
||||
*
|
||||
* @param {{path: string, file?: File, children: Array}} root Узел дерева.
|
||||
* @param {string} prefix Путь выбранной папки, в которую попал узел.
|
||||
* @returns {object} Тот же узел после изменения путей.
|
||||
*
|
||||
* Для leaf-файлов создаётся новый File с тем же базовым именем; логический путь
|
||||
* хранится отдельно в узле дерева.
|
||||
*/
|
||||
export function rebaseTree(root, prefix) {
|
||||
root.path = `${prefix}/${root.path}`;
|
||||
if (root.file) root.file = new File([root.file], root.file.name, { lastModified: root.file.lastModified });
|
||||
root.children.forEach((child) => rebaseTree(child, prefix));
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { esc } from './esc.js';
|
||||
import { fs } from './fs.js';
|
||||
|
||||
const DEFAULT_RENDER_CONFIG = {
|
||||
labels: {
|
||||
statusReady: 'готов',
|
||||
remove: 'Удалить',
|
||||
empty: 'Нет выбранных файлов',
|
||||
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
|
||||
count: (count, bytes) => `${count} файлов · ${fs(bytes)}`,
|
||||
},
|
||||
layout: { columns: ['path', 'size', 'status'] },
|
||||
};
|
||||
|
||||
function renderConfig(cfg = {}) {
|
||||
return {
|
||||
labels: {
|
||||
...DEFAULT_RENDER_CONFIG.labels,
|
||||
...(cfg.labels || {}),
|
||||
columns: {
|
||||
...DEFAULT_RENDER_CONFIG.labels.columns,
|
||||
...(cfg.labels?.columns || {}),
|
||||
},
|
||||
},
|
||||
layout: { ...DEFAULT_RENDER_CONFIG.layout, ...(cfg.layout || {}) },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит один узел дерева и его раскрытых потомков в HTML-строки таблицы.
|
||||
*
|
||||
* @param {{kind: string, id: string, name: string, path: string, file?: File,
|
||||
* children: Array, expanded: boolean, error?: string}} node Узел file/folder/zip.
|
||||
* @param {number} depth Глубина узла; используется для визуального отступа.
|
||||
* @returns {string} HTML одной строки или поддерева строк.
|
||||
*
|
||||
* Имена и пути проходят через esc до вставки в innerHTML. Группа получает
|
||||
* кнопку раскрытия, leaf-файл — размер, статус и data-path для будущего API статусов.
|
||||
*/
|
||||
function renderNode(node, depth, cfg) {
|
||||
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 remove = `<button class="remove-btn" type="button" data-remove="${node.id}" aria-label="${esc(cfg.labels.remove)} ${name}">×</button>`;
|
||||
const pathAttribute = node.kind === 'file' ? ` data-path="${esc(node.path)}"` : '';
|
||||
const cells = {
|
||||
path: action,
|
||||
size: node.kind === 'file' ? fs(node.file.size) : '',
|
||||
status: node.error ? esc(node.error) : node.kind === 'file' ? esc(cfg.labels.statusReady) : '',
|
||||
};
|
||||
const columns = cfg.layout.columns?.length
|
||||
? cfg.layout.columns
|
||||
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
|
||||
const row = `<tr class="tree-row tree-${node.kind}"${pathAttribute}>`
|
||||
+ columns.map((column) => `<td>${cells[column] || ''}</td>`).join('')
|
||||
+ `<td>${remove}</td></tr>`;
|
||||
if (!isGroup || !node.expanded) return row;
|
||||
// Дочерние строки добавляются только для раскрытой группы.
|
||||
return row + node.children.map((child) => renderNode(child, depth + 1, cfg)).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Полностью синхронизирует DOM таблицы и счётчик с текущим state.nodes.
|
||||
*
|
||||
* @param {{nodes: Array}} state Состояние дерева.
|
||||
* @param {{tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-вывод.
|
||||
* @returns {void}
|
||||
*
|
||||
* Обход для счётчика независим от раскрытия: свернутая группа всё равно входит
|
||||
* в количество leaf-файлов и суммарный размер.
|
||||
*/
|
||||
export function render(state, elements, cfg) {
|
||||
const normalized = renderConfig(cfg);
|
||||
const columns = normalized.layout.columns?.length
|
||||
? normalized.layout.columns
|
||||
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
|
||||
elements.tableBodyEl.innerHTML = state.nodes.length
|
||||
? state.nodes.map((node) => renderNode(node, 0, normalized)).join('')
|
||||
: `<tr><td colspan="${columns.length + 1}">${esc(normalized.labels.empty)}</td></tr>`;
|
||||
const files = [];
|
||||
// Собирает все leaf-файлы, включая содержимое свернутых групп.
|
||||
const visit = (node) => {
|
||||
if (node.kind === 'file') files.push(node);
|
||||
else node.children.forEach(visit);
|
||||
};
|
||||
state.nodes.forEach(visit);
|
||||
const totalBytes = files.reduce((sum, node) => sum + node.file.size, 0);
|
||||
elements.countEl.textContent = normalized.labels.count(files.length, totalBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ищет узел по уникальному id и возвращает также массив его непосредственного родителя.
|
||||
*
|
||||
* @param {Array} nodes Массив узлов текущего уровня.
|
||||
* @param {string} id Идентификатор, созданный через crypto.randomUUID().
|
||||
* @returns {{node: object, nodes: Array}|null} Узел и контейнер для удаления либо null.
|
||||
*/
|
||||
export function findNode(nodes, id) {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return { node, nodes };
|
||||
const found = findNode(node.children || [], id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Преобразует иерархическое дерево в плоский список leaf-файлов.
|
||||
*
|
||||
* @param {Array} nodes Узлы любого уровня.
|
||||
* @param {Array} result Внутренний аккумулятор рекурсивного вызова.
|
||||
* @returns {Array<{path: string, name: string, size: number, file: File}>} Файлы для приложения.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user