Document picker code and architecture

This commit is contained in:
“Naeel”
2026-09-05 13:24:00 +03:00
parent 13ea0efcd7
commit e4b4dca53a
18 changed files with 437 additions and 2 deletions
@@ -1,15 +1,31 @@
/**
* Добавляет узел файла или группы в состояние 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;
state.nodes.push(accepted);
return true;
+9
View File
@@ -1,3 +1,12 @@
/**
* Экранирует текст перед вставкой в HTML-шаблон, собираемый через innerHTML.
*
* @param {*} value Имя файла, путь или сообщение статуса.
* @returns {string} Строка с заменёнными HTML-значимыми символами.
*
* Экранируются и кавычки, потому что значение может попасть не только в текст
* кнопки, но и в HTML-атрибут вроде data-path или aria-label.
*/
export function esc(value) {
return String(value).replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
+6
View File
@@ -1,3 +1,9 @@
/**
* Преобразует размер файла из байтов в короткую строку для таблицы.
*
* @param {number} bytes Размер в байтах.
* @returns {string} Значение в B, KB или MB с одним знаком после запятой.
*/
export function fs(bytes) {
return bytes < 1024 ? `${bytes} B`
: bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB`
@@ -2,6 +2,13 @@ import { addFiles, onFilesChange } from './on_files_change.js';
import { onFolderChange } from './on_folder_change.js';
import { findNode, flattenFiles, render } from './render.js';
/**
* Удаляет ключи дедупликации для leaf-файлов внутри удаляемого поддерева.
*
* @param {{fileKeys: Set<string>}} state Состояние дедупликации.
* @param {{kind: string, path: string, file?: File, children?: Array}} node Удаляемый узел.
* @returns {void}
*/
function removeFileMeta(state, node) {
if (node.kind === 'file') {
state.fileKeys.delete(`${node.path}\u0000${node.file.size}`);
@@ -10,6 +17,13 @@ function removeFileMeta(state, node) {
node.children.forEach((child) => removeFileMeta(state, child));
}
/**
* Удаляет узел из дерева и синхронно освобождает его dedup-ключи.
*
* @param {{nodes: Array, fileKeys: Set<string>}} state Состояние таблицы.
* @param {string} id Идентификатор узла.
* @returns {void} Ничего не делает, если id не найден.
*/
function removeNode(state, id) {
const found = findNode(state.nodes, id);
if (!found) return;
@@ -17,17 +31,34 @@ function removeNode(state, id) {
found.nodes.splice(found.nodes.indexOf(found.node), 1);
}
/**
* Создаёт picker и возвращает его публичный API.
*
* @param {{allowedExt: string[], fileInputEl: HTMLInputElement,
* folderInputEl: HTMLInputElement, tableBodyEl: HTMLElement, countEl: HTMLElement}} cfg
* Конфигурация и обязательные DOM-элементы интегратора.
* @returns {{pickFiles: Function, pickFolder: Function, addFiles: Function,
* getFiles: Function, remove: Function, render: Function, clear: Function}}
* Управляющий API без прямого доступа к внутреннему state.
*
* Функция регистрирует DOM-события один раз, хранит дерево и dedup Set внутри
* замыкания и сразу рисует пустое состояние. Внешнее приложение получает только
* операции выбора, добавления, чтения и удаления.
*/
export function initUploadTable(cfg) {
// Нормализованный набор DOM-ссылок передаётся во все функции рендера.
const elements = {
fileInputEl: cfg.fileInputEl,
folderInputEl: cfg.folderInputEl,
tableBodyEl: cfg.tableBodyEl,
countEl: cfg.countEl,
};
// nodes — дерево; fileKeys — ключи path+NUL+size; busy защищает async change handlers.
const state = { nodes: [], fileKeys: new Set(), 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);
@@ -43,9 +74,12 @@ export function initUploadTable(cfg) {
});
const api = {
// Открывают системные диалоги, не обходя браузерные ограничения File API.
pickFiles: () => elements.fileInputEl.click(),
pickFolder: () => elements.folderInputEl.click(),
// Программное добавление использует тот же фильтр и ZIP-парсер, что и input.
addFiles: (files) => addFiles(state, cfg, files, elements),
// Возвращает только leaf-файлы; группы и внутренний state наружу не выдаются.
getFiles: () => flattenFiles(state.nodes),
remove: (id) => {
removeNode(state, id);
@@ -53,6 +87,7 @@ export function initUploadTable(cfg) {
},
render: () => render(state, elements),
clear: () => {
// Очистка сбрасывает и дерево, и dedup Set, чтобы повторный выбор был возможен.
state.nodes = [];
state.fileKeys.clear();
render(state, elements);
+29
View File
@@ -2,12 +2,26 @@ 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,
@@ -15,15 +29,29 @@ export async function addFiles(state, cfg, files, elements) {
continue;
}
try {
// listZipFiles возвращает дерево только с разрешёнными leaf-файлами.
const zipTree = await listZipFiles(file, cfg.allowedExt);
if (zipTree) addFileWithDedup(state, zipTree);
} catch (error) {
// Битый или небезопасный ZIP пропускается, чтобы не блокировать picker.
continue;
}
}
// Единая перерисовка после всей пачки предотвращает промежуточные состояния UI.
render(state, elements);
}
/**
* Создаёт обработчик 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;
@@ -31,6 +59,7 @@ export function onFilesChange(state, cfg, elements) {
try {
await addFiles(state, cfg, elements.fileInputEl.files, elements);
} finally {
// Даже исключение вне внутреннего ZIP-catch не оставляет picker заблокированным.
state.busy = false;
}
};
+20
View File
@@ -3,6 +3,19 @@ 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;
@@ -10,15 +23,18 @@ export function onFolderChange(state, cfg, elements) {
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: true });
}
const root = roots.get(rootName);
// Вставляет узел по его пути, создавая отсутствующие промежуточные папки.
const addToFolder = (node) => {
let current = root;
const nodeParts = node.path.split('/').slice(1);
@@ -36,12 +52,14 @@ export function onFolderChange(state, cfg, elements) {
};
if (lowerPath.endsWith('.zip')) {
try {
// ZIP rebased на имя выбранной папки перед вставкой в общий root.
const zip = await listZipFiles(file, cfg.allowedExt);
if (zip) {
rebaseTree(zip, rootName);
addToFolder(zip);
}
} catch (error) {
// Ошибка одного ZIP не должна терять остальные файлы каталога.
continue;
}
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
@@ -49,7 +67,9 @@ export function onFolderChange(state, cfg, elements) {
path: `${rootName}/${relativePath}`, file, children: [], expanded: true });
}
}
// Дедупликация выполняется после сборки каждого корня каталога.
roots.forEach((root) => addFileWithDedup(state, root));
// Сброс value позволяет выбрать тот же каталог повторно.
elements.folderInputEl.value = '';
render(state, elements);
} finally {
+10
View File
@@ -1,3 +1,13 @@
/**
* Добавляет префикс корневой папки к каждому пути дерева.
*
* @param {{path: string, file?: File, children: Array}} root Узел дерева.
* @param {string} prefix Путь выбранной папки, в которую попал узел.
* @returns {object} Тот же узел после изменения путей.
*
* Для leaf-файлов создаётся новый File с обновлённым именем, чтобы метаданные
* browser File и путь, возвращаемый через getFiles(), оставались согласованными.
*/
export function rebaseTree(root, prefix) {
root.path = `${prefix}/${root.path}`;
if (root.file) root.file = new File([root.file], root.path, { lastModified: root.file.lastModified });
+37
View File
@@ -1,6 +1,17 @@
import { esc } from './esc.js';
import { fs } from './fs.js';
/**
* Рендерит один узел дерева и его раскрытых потомков в 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) {
const isGroup = node.kind !== 'file';
const padding = depth * 4;
@@ -15,14 +26,26 @@ function renderNode(node, depth) {
+ `<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('');
}
/**
* Полностью синхронизирует DOM таблицы и счётчик с текущим state.nodes.
*
* @param {{nodes: Array}} state Состояние дерева.
* @param {{tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-вывод.
* @returns {void}
*
* Обход для счётчика независим от раскрытия: свернутая группа всё равно входит
* в количество leaf-файлов и суммарный размер.
*/
export function render(state, elements) {
elements.tableBodyEl.innerHTML = state.nodes.length
? state.nodes.map((node) => renderNode(node, 0)).join('')
: '<tr><td colspan="4">Нет выбранных файлов</td></tr>';
const files = [];
// Собирает все leaf-файлы, включая содержимое свернутых групп.
const visit = (node) => {
if (node.kind === 'file') files.push(node);
else node.children.forEach(visit);
@@ -31,6 +54,13 @@ export function render(state, elements) {
elements.countEl.textContent = `${files.length} файлов · ${fs(files.reduce((sum, node) => sum + node.file.size, 0))}`;
}
/**
* Ищет узел по уникальному 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 };
@@ -40,6 +70,13 @@ export function findNode(nodes, id) {
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 });
+11
View File
@@ -1,5 +1,16 @@
import { flattenFiles } from './render.js';
/**
* Legacy helper: заменяет HTML статуса у найденного leaf-файла.
*
* @param {string} path Полный логический путь файла в текущем дереве.
* @param {string} html Готовая HTML-строка статуса; вызывающая сторона отвечает
* за её безопасность.
* @param {{nodes: Array}} state Состояние дерева, из которого ищется файл.
* @param {{tableBodyEl: HTMLElement}} elements DOM-элементы старого renderer.
* @returns {void} Ничего не возвращает; при отсутствии файла или ячейки ничего
* не изменяет.
*/
export function setStatus(path, html, state, elements) {
const file = flattenFiles(state.nodes).find((item) => item.path === path);
if (!file) return;
+34
View File
@@ -1,15 +1,26 @@
// Построить дерево ZIP с файлами только разрешённых расширений.
import { rebaseTree } from '../table/rebase_tree.js';
/** Возвращает true, если имя заканчивается одним из разрешённых расширений. */
function extensionAllowed(name, allowedExt) {
const lowerName = name.toLowerCase();
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
}
/** Создаёт browser File из байтов ZIP entry и задаёт ему полный логический путь. */
function makeFile(data, name) {
return new File([data], name);
}
/**
* Валидирует путь ZIP entry до его использования в дереве.
*
* @param {string} entryName Сырой путь из центрального каталога ZIP.
* @returns {string[]|null} Безопасные сегменты пути или null для опасной записи.
*
* Отклоняются абсолютные пути, backslash, пустые сегменты, `.` и `..`.
* Это предотвращает traversal и появление ложных групп в UI.
*/
function safeEntryParts(entryName) {
if (!entryName || entryName.startsWith('/') || entryName.includes('\\')) return null;
const parts = entryName.split('/');
@@ -17,10 +28,12 @@ function safeEntryParts(entryName) {
return parts;
}
/** Создаёт единый узел file, folder или zip с уникальным id и раскрытым состоянием. */
function node(kind, name, path, children = [], file = null) {
return { id: crypto.randomUUID(), kind, name, path, children, file, expanded: true };
}
/** Вставляет leaf или вложенное дерево по сегментам пути, создавая folder-узлы. */
function addPath(root, parts, fileNode) {
let current = root;
parts.forEach((part, index) => {
@@ -36,17 +49,30 @@ function addPath(root, parts, fileNode) {
});
}
/**
* Рекурсивно читает байтовый массив ZIP и строит дерево разрешённых файлов.
*
* @param {Uint8Array} data Распаковываемые байты ZIP.
* @param {string} zipName Имя текущего архива для корневого узла и пути.
* @param {string[]} allowedExt Разрешённые расширения документов.
* @param {number} depth Текущая глубина вложенных ZIP.
* @returns {Promise<object|null>} Дерево или null, если разрешённых leaf нет.
* @throws {Error} При превышении глубины или повреждённом ZIP.
*/
async function listEntries(data, zipName, allowedExt, depth) {
// Ограничение глубины защищает браузер от бесконечной/чрезмерной рекурсии.
if (depth > 20) throw new Error('Слишком глубокая вложенность ZIP');
const entries = fflate.unzipSync(data);
const root = node('zip', zipName, zipName);
for (const [entryName, entryData] of Object.entries(entries)) {
// Каталоги ZIP не являются файлами и будут созданы addPath при необходимости.
if (entryName.endsWith('/')) continue;
const parts = safeEntryParts(entryName);
if (!parts) continue;
const normalizedName = parts.join('/');
if (normalizedName.toLowerCase().endsWith('.zip')) {
// Вложенный ZIP раскрывается только если внутри есть разрешённые leaf-файлы.
const nested = await listEntries(entryData, entryName, allowedExt, depth + 1);
if (nested) {
rebaseTree(nested, zipName);
@@ -54,6 +80,7 @@ async function listEntries(data, zipName, allowedExt, depth) {
addPath(root, parts, nested);
}
} else if (extensionAllowed(normalizedName, allowedExt)) {
// Запрещённые документы отбрасываются до создания browser File.
const path = `${zipName}/${normalizedName}`;
const file = makeFile(entryData, path);
addPath(root, parts, node('file', parts.at(-1), path, [], file));
@@ -62,6 +89,13 @@ async function listEntries(data, zipName, allowedExt, depth) {
return root.children.length ? root : null;
}
/**
* Публично читает выбранный ZIP-файл и возвращает его фильтрованное дерево.
*
* @param {File} file Browser File с ZIP-содержимым.
* @param {string[]} allowedExt Разрешённые расширения.
* @returns {Promise<object|null>} Корневой zip-узел или null для пустого результата.
*/
export async function listZipFiles(file, allowedExt) {
const data = new Uint8Array(await file.arrayBuffer());
return listEntries(data, file.name, allowedExt, 0);