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 leafStatuses(node, state, result = []) { if (node.kind === 'file') { result.push(state.statuses.get(node.id) || { kind: 'ready', text: '' }); } else { node.children.forEach((child) => leafStatuses(child, state, result)); } return result; } function renderStatus(node, state, cfg) { if (node.kind === 'file') { const status = state.statuses.get(node.id) || { kind: 'ready', text: cfg.labels.statusReady }; const kind = ['uploading', 'parsing', 'parsed', 'duplicate', 'error'].includes(status.kind) ? status.kind : 'ready'; return `${esc(status.text || cfg.labels.statusReady)}`; } const statuses = leafStatuses(node, state); const errors = statuses.filter((status) => status.kind === 'error').length; const uploading = statuses.filter((status) => status.kind === 'uploading' || status.kind === 'parsing').length; const parsed = statuses.filter((status) => ['parsed', 'duplicate'].includes(status.kind)).length; if (uploading) return `${uploading} загружается/обрабатывается`; if (errors) return `${errors} ошибок`; if (parsed === statuses.length) return `${statuses.length} готово`; return `${parsed}/${statuses.length} обработано`; } function renderNode(node, depth, cfg, state) { const isGroup = node.kind !== 'file'; const padding = depth * 4; const name = esc(node.name); const action = isGroup ? `` : `${name}`; const documentId = state.documents.get(node.id); const details = node.kind === 'file' && documentId ? `` + `` : ''; const remove = ``; const pathAttribute = ` data-path="${esc(node.path)}"`; const fileIdAttribute = node.kind === 'file' ? ` data-file-id="${esc(node.id)}"` : ''; const cells = { path: action, size: node.kind === 'file' ? fs(node.file.size) : '', status: node.error ? esc(node.error) : renderStatus(node, state, cfg), }; const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(DEFAULT_RENDER_CONFIG.labels.columns); const row = `` + columns.map((column) => `${cells[column] || ''}`).join('') + `${details}${remove}`; if (!isGroup || !node.expanded) return row; // Дочерние строки добавляются только для раскрытой группы. return row + node.children.map((child) => renderNode(child, depth + 1, cfg, state)).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, state)).join('') : `${esc(normalized.labels.empty)}`; 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<{id: string, path: string, name: string, size: number, file: File}>} Файлы для приложения. */ export function flattenFiles(nodes, result = []) { nodes.forEach((node) => { if (node.kind === 'file') result.push({ id: node.id, path: node.path, name: node.name, size: node.file.size, file: node.file }); else flattenFiles(node.children, result); }); return result; }