Integrate Obfuscator file picker into Contracts
Deploy contracts-flask / validate (push) Canceled after 0s
Deploy contracts-flask / validate (push) Canceled after 0s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,47 +1,54 @@
|
||||
// addFileWithDedup — дедуп «имя+размер», суффиксы _2/_3, лимиты (over).
|
||||
// Мутирует state. Возвращает true если файл добавлен (или переведён в over),
|
||||
// false если это дедуп (обновлена только дата).
|
||||
|
||||
export function addFileWithDedup(state, file, cfg) {
|
||||
const size = file.size;
|
||||
const mtime = file.lastModified;
|
||||
let name = file.name;
|
||||
const maxFileBytes = cfg.maxFileBytes;
|
||||
const maxSessionBytes = cfg.maxSessionBytes;
|
||||
|
||||
if (state.fileMeta.has(name)) {
|
||||
const e = state.fileMeta.get(name);
|
||||
if (e.size === size) {
|
||||
// Тот же файл (имя+размер) — дедуп. Дату не сравниваем: файл могли пересохранить
|
||||
// с тем же содержимым. Оставляем более свежий по дате.
|
||||
if (mtime > e.mtime) {
|
||||
e.mtime = mtime;
|
||||
const idx = state.files.findIndex(f => f.name === name);
|
||||
if (idx >= 0) state.files[idx] = new File([file], name, { lastModified: mtime });
|
||||
}
|
||||
return false; // дедуп: файл не добавлен (обновлена только дата)
|
||||
/**
|
||||
* Добавляет узел файла или группы в состояние 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;
|
||||
}
|
||||
// Имя то же, размер другой — добавить с суффиксом _2, _3...
|
||||
const dot = name.lastIndexOf('.');
|
||||
const base = dot > 0 ? name.slice(0, dot) : name;
|
||||
const ext = dot > 0 ? name.slice(dot) : '';
|
||||
let n = 2;
|
||||
while (state.fileMeta.has(base + '_' + n + ext)) n++;
|
||||
name = base + '_' + n + ext;
|
||||
}
|
||||
state.fileMeta.set(name, { size, mtime });
|
||||
|
||||
// Определяем, превышает ли файл лимит (по размеру файла или суммарный) — не участвует в обфускации
|
||||
let over = false;
|
||||
if (size > maxFileBytes) over = true; // лимит на один файл
|
||||
const sum = state.files.reduce((s, f) => s + (state.overNames.has(f.name) ? 0 : f.size), 0);
|
||||
if (sum + size > maxSessionBytes) over = true; // суммарный лимит сессии
|
||||
|
||||
if (over) {
|
||||
state.overNames.add(name);
|
||||
state.files.push(new File([file], name, { lastModified: mtime }));
|
||||
return true;
|
||||
}
|
||||
state.files.push(new File([file], name, { lastModified: mtime }));
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
// esc — экранирование HTML (защита от self-XSS именами файлов).
|
||||
|
||||
export function esc(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
/**
|
||||
* Экранирует текст перед вставкой в HTML-шаблон, собираемый через innerHTML.
|
||||
*
|
||||
* @param {*} value Имя файла, путь или сообщение статуса.
|
||||
* @returns {string} Строка с заменёнными HTML-значимыми символами.
|
||||
*
|
||||
* Экранируются и кавычки, потому что значение может попасть не только в текст
|
||||
* кнопки, но и в HTML-атрибут вроде data-path или aria-label.
|
||||
*/
|
||||
export function esc(value) {
|
||||
return String(value).replace(/[&<>"']/g, (character) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[character]));
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// estForFile — эмпирическая оценка времени обработки файла: сек/МБ (ориентировочно, до старта).
|
||||
|
||||
export const DEFAULT_EST_MB_SEC = 12;
|
||||
|
||||
export function estForFile(f, estMbSec) {
|
||||
const k = estMbSec || DEFAULT_EST_MB_SEC;
|
||||
return f ? Math.max(1, Math.round(f.size / 1048576 * k)) : 0;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// fmtSec — формат секунд: "Nс" / "Nм Nс".
|
||||
|
||||
export function fmtSec(s) {
|
||||
s = Math.max(0, Math.round(s));
|
||||
return s >= 60 ? Math.floor(s / 60) + 'м ' + (s % 60) + 'с' : s + 'с';
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
// fs — формат размера: B / KB / MB.
|
||||
|
||||
export function fs(b) {
|
||||
return b < 1024 ? b + ' B'
|
||||
: b < 1048576 ? (b / 1024).toFixed(1) + ' KB'
|
||||
: (b / 1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
/**
|
||||
* Преобразует размер файла из байтов в короткую строку для таблицы.
|
||||
*
|
||||
* @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`;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
// initUploadTable — сборка слоя 1 (выбор файлов/папки/архива).
|
||||
// Состояние (files/fileMeta/overNames) живёт внутри модуля.
|
||||
|
||||
import { addFiles as addFilesToState, makeFilesChangeHandler } from './on_files_change.js';
|
||||
import { makeFolderChangeHandler } from './on_folder_change.js';
|
||||
import { render } from './render.js';
|
||||
import { setStatus } from './set_status.js';
|
||||
import { rmFile } from './rm_file.js';
|
||||
|
||||
// cfg: {allowedExt, maxFileBytes, maxSessionBytes, estMbSec}
|
||||
// els: {fileInput, folderInput, tableBody, countEl, uploadBtnEl, onStatus(cls, text)}
|
||||
// returns api (см. README.md)
|
||||
export function initUploadTable(cfg, els) {
|
||||
const state = {
|
||||
files: [], // File[]
|
||||
fileMeta: new Map(), // имя -> {size, mtime} для дедупа/суффиксов
|
||||
overNames: new Set(), // имена файлов сверх лимита (не участвуют)
|
||||
busy: false, // идёт загрузка/обработка — список заблокирован
|
||||
proc: null, // {phase, procState, procExtractRate} — задаёт слой 3
|
||||
};
|
||||
|
||||
const onFilesChange = makeFilesChangeHandler({ state, cfg, els, onStatus: els.onStatus });
|
||||
const onFolderChange = makeFolderChangeHandler({ state, cfg, els, onStatus: els.onStatus });
|
||||
|
||||
els.fileInput.addEventListener('change', onFilesChange);
|
||||
els.folderInput.addEventListener('change', onFolderChange);
|
||||
// Делегирование кликов по кнопкам «✕» (удалить строку)
|
||||
els.tableBody.addEventListener('click', e => {
|
||||
const btn = e.target.closest('.remove-btn');
|
||||
if (btn) rmFile(state, els, cfg, Number(btn.dataset.idx));
|
||||
});
|
||||
|
||||
return {
|
||||
state, // доступ для слоя 3 (установить state.proc для render)
|
||||
addFiles(files) { return addFilesToState(state, cfg, files, els, els.onStatus); },
|
||||
getFiles() { // ТОЛЬКО учитываемые (без over): [{name, size, file}]
|
||||
return state.files.filter(f => !state.overNames.has(f.name))
|
||||
.map(f => ({ name: f.name, size: f.size, file: f }));
|
||||
},
|
||||
getOverNames() { return state.overNames; },
|
||||
setStatus(idx, html) { setStatus(idx, html); },
|
||||
render() { render(state, els, cfg); },
|
||||
clear() {
|
||||
state.files = [];
|
||||
state.fileMeta = new Map();
|
||||
state.overNames = new Set();
|
||||
if (els.fileInput) els.fileInput.value = '';
|
||||
render(state, els, cfg);
|
||||
},
|
||||
setBusy(b) {
|
||||
state.busy = b;
|
||||
if (els.fileInput) els.fileInput.disabled = b;
|
||||
},
|
||||
_rm(i) { rmFile(state, els, cfg, i); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* lst_filter — фильтрация файлов каталога по manifest-файлу `obscure.lst`.
|
||||
*
|
||||
* Модуль намеренно чистый: не обращается к DOM, сети, upload или Flask.
|
||||
* На вход получает список File из folder picker и набор поддерживаемых
|
||||
* расширений (включая '.zip'), на выходе отдаёт отфильтрованный список File
|
||||
* в исходном порядке и диагностику для интерфейса.
|
||||
*
|
||||
* Формат manifest:
|
||||
* - одна строка — одно правило;
|
||||
* - пустые строки игнорируются;
|
||||
* - строки, начинающиеся с '#', — комментарии;
|
||||
* - '!' в начале строки задаёт исключение;
|
||||
* - правила задают пути относительно выбранной папки;
|
||||
* - положительные правила собирают кандидатов, исключения удаляют совпадения.
|
||||
*
|
||||
* Glob-семантика:
|
||||
* - '*' — ноль или больше символов в одном сегменте (не '/');
|
||||
* - '?' — один символ, кроме '/';
|
||||
* - '**' — целый сегмент: рекурсивное совпадение через каталоги;
|
||||
* внутри сегмента ('a**b') эквивалентен '*';
|
||||
* - '[abc]', '[a-z]' — классы и диапазоны символов;
|
||||
* - '[!a]', '[^a]' — отрицательные классы символов;
|
||||
* - 'reports/' эквивалентно 'reports/**';
|
||||
* - путь без glob-символов — точное совпадение;
|
||||
* - сравнение всего пути без учёта регистра.
|
||||
*/
|
||||
|
||||
/** Фиксированное имя manifest-файла (ищется только в корне выбранной папки). */
|
||||
const MANIFEST_NAME = 'obscure.lst';
|
||||
|
||||
/**
|
||||
* Возвращает true, если имя файла оканчивается одним из разрешённых расширений.
|
||||
*
|
||||
* Сравнение без учёта регистра: 'A.PDF' и '.pdf' считаются одинаковыми.
|
||||
*
|
||||
* @param {string} name Имя файла.
|
||||
* @param {string[]} allowedExt Список расширений (с точкой) включая '.zip'.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function extensionAllowed(name, allowedExt) {
|
||||
const lowerName = String(name || '').toLowerCase();
|
||||
return allowedExt.some((extension) => lowerName.endsWith(String(extension).toLowerCase()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Вычисляет путь файла относительно корня выбранной папки.
|
||||
*
|
||||
* Браузер для folder picker кладёт в `webkitRelativePath` путь вида
|
||||
* `<корень>/подпапка/файл`; первый сегмент (имя выбранной папки) отбрасываем.
|
||||
* Обратные слеши нормализуются в '/', чтобы правило можно было писать
|
||||
* в любом стиле.
|
||||
*
|
||||
* @param {{webkitRelativePath?: string, name: string}} file Файл или его фейк.
|
||||
* @returns {string} Путь относительно выбранной папки.
|
||||
*/
|
||||
function relativePathOf(file) {
|
||||
const raw = String(file.webkitRelativePath || file.name || '').replace(/\\/g, '/');
|
||||
const parts = raw.split('/');
|
||||
return parts.length > 1 ? parts.slice(1).join('/') : (parts[0] || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ищет ровно корневой `obscure.lst` (без учёта регистра).
|
||||
*
|
||||
* Manifest — это файл на глубине 1 от выбранной папки. Вложенный
|
||||
* `subdir/obscure.lst` манифестом не считается; другие `*.lst` тоже.
|
||||
*
|
||||
* @param {Array} files Список файлов folder picker.
|
||||
* @returns {object|null} Найденный manifest либо null.
|
||||
*/
|
||||
function findManifest(files) {
|
||||
return files.find((file) => {
|
||||
const raw = String(file.webkitRelativePath || file.name || '').replace(/\\/g, '/');
|
||||
const parts = raw.split('/');
|
||||
return parts.length === 2 && parts[1].toLowerCase() === MANIFEST_NAME;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Экранирует символы регулярного выражения, чтобы glob-литералы
|
||||
* не превращались в regex-метасимволы.
|
||||
*
|
||||
* @param {string} text Произвольная строка.
|
||||
* @returns {string} Строка, безопасная для вставки в RegExp.
|
||||
*/
|
||||
function escapeRegExp(text) {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Находит закрывающую ']' для класса символов.
|
||||
*
|
||||
* Учитывает, что ']' сразу после '[' или '[!' / '[^' является литералом,
|
||||
* а не закрывающей скобкой.
|
||||
*
|
||||
* @param {string} glob Исходный шаблон.
|
||||
* @param {number} openIndex Позиция открывающей '['.
|
||||
* @returns {number} Индекс закрывающей ']' либо -1, если класс не закрыт.
|
||||
*/
|
||||
function findClosingBracket(glob, openIndex) {
|
||||
let index = openIndex + 1;
|
||||
if (glob[index] === '!' || glob[index] === '^') index += 1;
|
||||
if (glob[index] === ']') index += 1;
|
||||
for (; index < glob.length; index += 1) {
|
||||
if (glob[index] === ']') return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Компилирует glob в регистронезависимый RegExp.
|
||||
*
|
||||
* @param {string} glob Нормализованный шаблон правила.
|
||||
* @returns {RegExp|null} null, если шаблон синтаксически некорректен
|
||||
* (например, незакрытый класс символов).
|
||||
*/
|
||||
function compileGlob(glob) {
|
||||
let pattern = '^';
|
||||
let index = 0;
|
||||
|
||||
while (index < glob.length) {
|
||||
const character = glob[index];
|
||||
|
||||
if (character === '*') {
|
||||
const isDouble = glob[index + 1] === '*';
|
||||
if (!isDouble) {
|
||||
// Одиночная звёздочка: любой набор символов внутри одного сегмента.
|
||||
pattern += '[^/]*';
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
// Двойная звёздочка рекурсивна только как целый сегмент пути.
|
||||
const atSegmentStart = index === 0 || glob[index - 1] === '/';
|
||||
const atSegmentEnd = index + 2 === glob.length || glob[index + 2] === '/';
|
||||
if (atSegmentStart && atSegmentEnd) {
|
||||
if (glob[index + 2] === '/') {
|
||||
// '**/' — ноль или больше сегментов вместе с завершающим '/'.
|
||||
pattern += '(?:[^/]+/)*';
|
||||
index += 3;
|
||||
} else {
|
||||
// '**' в конце правила — любой остаток пути.
|
||||
pattern += '.*';
|
||||
index += 2;
|
||||
}
|
||||
} else {
|
||||
// 'a**b' внутри сегмента эквивалентно 'a*b' и не пересекает '/'.
|
||||
pattern += '[^/]*';
|
||||
index += 2;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '?') {
|
||||
// Ровно один символ, но не разделитель каталогов.
|
||||
pattern += '[^/]';
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '[') {
|
||||
const close = findClosingBracket(glob, index);
|
||||
if (close === -1) return null;
|
||||
let body = glob.slice(index + 1, close);
|
||||
// '[!a]' — традиционная запись отрицательного класса, '[^a]' — regex-стиль.
|
||||
if (body.startsWith('!')) body = `^${body.slice(1)}`;
|
||||
pattern += `[${body.replace(/[\\\]]/g, (symbol) => `\\${symbol}`)}]`;
|
||||
index = close + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
pattern += escapeRegExp(character);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
pattern += '$';
|
||||
|
||||
try {
|
||||
return new RegExp(pattern, 'i');
|
||||
} catch (error) {
|
||||
// Некорректный класс (например, '[]') Regexp не компилирует — правило
|
||||
// отклоняется, остальные правила продолжают обрабатываться.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализует одну строку manifest в путь правила.
|
||||
*
|
||||
* @param {string} line Исходная строка.
|
||||
* @returns {null|{error: string}|{path: string, negative: boolean}}
|
||||
* null — пустая строка или комментарий;
|
||||
* {error} — некорректное правило;
|
||||
* {path, negative} — нормализованное правило.
|
||||
*/
|
||||
function normalizeRule(line) {
|
||||
let text = line.trim();
|
||||
|
||||
// Пустые строки и комментарии не являются правилами.
|
||||
if (!text || text.startsWith('#')) return null;
|
||||
|
||||
let negative = false;
|
||||
if (text.startsWith('!')) {
|
||||
negative = true;
|
||||
text = text.slice(1).trim();
|
||||
// Голое '!' — ошибка с указанием номера строки.
|
||||
if (!text) return { error: 'пустое правило после "!"' };
|
||||
}
|
||||
|
||||
// Единый разделитель, схлопывание повторных '/' и устранение ведущих './'.
|
||||
// Схлопывание идёт до снятия './', чтобы './/reports' превращалось в
|
||||
// 'reports', а не в абсолютный '/reports'.
|
||||
text = text.replace(/\\/g, '/');
|
||||
text = text.replace(/\/{2,}/g, '/');
|
||||
text = text.replace(/^(?:\.\/)+/, '');
|
||||
|
||||
// Абсолютные пути (POSIX и Windows drive) недопустимы.
|
||||
if (text.startsWith('/') || /^[a-zA-Z]:\//.test(text)) {
|
||||
return { error: 'абсолютный путь не допускается' };
|
||||
}
|
||||
|
||||
// Выход за пределы выбранной папки запрещён.
|
||||
if (text.split('/').some((segment) => segment === '..')) {
|
||||
return { error: 'сегмент ".." не допускается' };
|
||||
}
|
||||
|
||||
if (!text) return { error: 'пустое правило' };
|
||||
|
||||
// Правило 'reports/' означает всё поддерево reports.
|
||||
if (text.endsWith('/')) text += '**';
|
||||
|
||||
return { path: text, negative };
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбирает текст manifest в массив правил.
|
||||
*
|
||||
* Некорректные строки не прерывают разбор: они попадают в diagnostics
|
||||
* с номером строки, остальные правила продолжают работать.
|
||||
*
|
||||
* @param {string} text Содержимое manifest (BOM уже снят).
|
||||
* @param {Array} diagnostics Аккумулятор диагностики.
|
||||
* @returns {Array<{line: number, source: string, negative: boolean, regex: RegExp}>}
|
||||
*/
|
||||
function parseRules(text, diagnostics) {
|
||||
// Поддерживаем CRLF, LF и одиночный CR.
|
||||
const lines = text.split(/\r\n|\r|\n/);
|
||||
const rules = [];
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const lineNumber = index + 1;
|
||||
const normalized = normalizeRule(line);
|
||||
if (normalized === null) return;
|
||||
|
||||
if (normalized.error) {
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
line: lineNumber,
|
||||
message: `${normalized.error}: "${line.trim()}"`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const regex = compileGlob(normalized.path);
|
||||
if (!regex) {
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
line: lineNumber,
|
||||
message: `некорректный glob: "${line.trim()}"`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
rules.push({
|
||||
line: lineNumber,
|
||||
source: line.trim(),
|
||||
negative: normalized.negative,
|
||||
regex,
|
||||
});
|
||||
});
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Фильтрует файлы каталога по manifest-файлу `obscure.lst`.
|
||||
*
|
||||
* Поведение:
|
||||
* - нет корневого manifest — возвращаются все файлы (прежнее поведение);
|
||||
* - manifest есть — результат строится только по его правилам, fallback нет;
|
||||
* - сам manifest никогда не попадает в результат;
|
||||
* - файлы неподдерживаемых типов не попадают в кандидаты;
|
||||
* - ZIP фильтруется по имени архива, внутренние имена не анализируются.
|
||||
*
|
||||
* @param {FileList|File[]} files Файлы, выбранные через folder picker.
|
||||
* @param {string[]} allowedExt Поддерживаемые расширения, включая '.zip'.
|
||||
* @returns {Promise<{
|
||||
* manifest: object|null,
|
||||
* files: Array,
|
||||
* diagnostics: Array<{level: string, line: number, message: string}>
|
||||
* }>}
|
||||
*/
|
||||
export async function lstFilter(files, allowedExt) {
|
||||
const list = Array.from(files || []);
|
||||
const manifest = findManifest(list);
|
||||
|
||||
// Manifest отсутствует: сохраняем прежнее поведение без какой-либо фильтрации.
|
||||
if (!manifest) {
|
||||
return { manifest: null, files: list, diagnostics: [] };
|
||||
}
|
||||
|
||||
const diagnostics = [];
|
||||
|
||||
// Ошибка чтения или UTF-8-декодирования: предупреждение и пустой результат.
|
||||
// TextDecoder с fatal:true не подменяет битые байты на U+FFFD молча.
|
||||
let text;
|
||||
try {
|
||||
const buffer = await manifest.arrayBuffer();
|
||||
text = new TextDecoder('utf-8', { fatal: true }).decode(buffer);
|
||||
} catch (error) {
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
line: 0,
|
||||
message: `не удалось прочитать ${MANIFEST_NAME}: ${error.message}`,
|
||||
});
|
||||
return { manifest, files: [], diagnostics };
|
||||
}
|
||||
|
||||
// BOM в начале снимаем вручную, иначе он попадёт в первое правило.
|
||||
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
|
||||
|
||||
const rules = parseRules(text, diagnostics);
|
||||
const positives = rules.filter((rule) => !rule.negative);
|
||||
const negatives = rules.filter((rule) => rule.negative);
|
||||
|
||||
// Нет ни одного корректного включающего правила — пустой результат без fallback.
|
||||
if (positives.length === 0) {
|
||||
diagnostics.push({
|
||||
level: 'warning',
|
||||
line: 0,
|
||||
message: `в ${MANIFEST_NAME} нет корректных включающих правил`,
|
||||
});
|
||||
return { manifest, files: [], diagnostics };
|
||||
}
|
||||
|
||||
// Кандидаты: только поддерживаемые типы, без самого manifest.
|
||||
// ZIP остаётся обычным кандидатом — include/exclude применяются к его имени.
|
||||
const candidates = list.filter((file) => file !== manifest
|
||||
&& extensionAllowed(file.name, allowedExt));
|
||||
|
||||
// Проход по файлам (а не по правилам) сохраняет исходный порядок и
|
||||
// исключает появление дублей при пересекающихся масках.
|
||||
const matched = new Set();
|
||||
const selected = [];
|
||||
|
||||
for (const file of candidates) {
|
||||
const path = relativePathOf(file);
|
||||
|
||||
let included = false;
|
||||
for (const rule of positives) {
|
||||
if (rule.regex.test(path)) {
|
||||
matched.add(rule);
|
||||
included = true;
|
||||
}
|
||||
}
|
||||
if (!included) continue;
|
||||
|
||||
// Исключение применяется после положительного набора.
|
||||
const excluded = negatives.some((rule) => rule.regex.test(path));
|
||||
if (!excluded) selected.push(file);
|
||||
}
|
||||
|
||||
// Правило, не совпавшее ни с одним поддерживаемым файлом, — предупреждение,
|
||||
// остальные правила продолжают работать.
|
||||
for (const rule of positives) {
|
||||
if (!matched.has(rule)) {
|
||||
diagnostics.push({
|
||||
level: 'warning',
|
||||
line: rule.line,
|
||||
message: `правило не совпало ни с одним файлом: "${rule.source}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.length === 0) {
|
||||
diagnostics.push({
|
||||
level: 'warning',
|
||||
line: 0,
|
||||
message: 'manifest не выбрал ни одного файла',
|
||||
});
|
||||
}
|
||||
|
||||
return { manifest, files: selected, diagnostics };
|
||||
}
|
||||
@@ -1,56 +1,75 @@
|
||||
// Обработчик <input type="file" multiple> change: дедуп + раскрытие ZIP + фильтр.
|
||||
// Экспортируется и чистая логика добавления массива файлов (addFiles),
|
||||
// и фабрика обработчика для input (makeFilesChangeHandler).
|
||||
|
||||
import { listZipFiles } from '../zip/list_zip_files.js';
|
||||
import { addFileWithDedup } from './add_file_with_dedup.js';
|
||||
import { render } from './render.js';
|
||||
|
||||
// Добавить массив файлов в список (дедуп + раскрытие ZIP + лимиты).
|
||||
// files: File[]. els.fileInput — для синхронизации input.files (DataTransfer),
|
||||
// чтобы повторный выбор того же файла сработал. onStatus(cls, text) — колбэк сообщений.
|
||||
export async function addFiles(state, cfg, files, els, onStatus) {
|
||||
const incoming = Array.from(files);
|
||||
const hasZip = incoming.some(f => f.name.toLowerCase().endsWith('.zip'));
|
||||
if (hasZip) {
|
||||
document.body.style.cursor = 'wait';
|
||||
if (onStatus) onStatus('progress', 'Разбираю архивы…');
|
||||
}
|
||||
try {
|
||||
for (const f of incoming) {
|
||||
if (f.name.toLowerCase().endsWith('.zip')) {
|
||||
try {
|
||||
const nested = await listZipFiles(f, cfg.allowedExt);
|
||||
if (nested.length) nested.forEach(x => addFileWithDedup(state, x, cfg));
|
||||
else addFileWithDedup(state, f, cfg); // в архиве нет документов — архив как есть
|
||||
} catch (err) {
|
||||
addFileWithDedup(state, f, cfg); // не удалось распаковать — zip как есть
|
||||
}
|
||||
} else {
|
||||
addFileWithDedup(state, f, cfg);
|
||||
/**
|
||||
* Обрабатывает список файлов из обычного 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;
|
||||
}
|
||||
} finally {
|
||||
if (hasZip) {
|
||||
document.body.style.cursor = '';
|
||||
if (onStatus) onStatus('', '');
|
||||
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;
|
||||
}
|
||||
}
|
||||
// Синхронизировать input.files — чтобы повторный выбор того же файла сработал
|
||||
if (els && els.fileInput && els.fileInput.files) {
|
||||
const d = new DataTransfer();
|
||||
state.files.forEach(f => d.items.add(f));
|
||||
els.fileInput.files = d.files;
|
||||
}
|
||||
render(state, els, cfg);
|
||||
// Единая перерисовка после всей пачки предотвращает промежуточные состояния UI.
|
||||
render(state, elements, cfg);
|
||||
}
|
||||
|
||||
// Фабрика обработчика change для <input type="file">.
|
||||
// ctx = { state, cfg, els, onStatus }
|
||||
export function makeFilesChangeHandler(ctx) {
|
||||
const { state, cfg, els, onStatus } = ctx;
|
||||
return () => {
|
||||
if (state.busy) return; // во время загрузки/обработки менять список нельзя
|
||||
addFiles(state, cfg, els.fileInput.files, els, onStatus);
|
||||
/**
|
||||
* Создаёт обработчик 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;
|
||||
// Сброс статуса перед новым выбором: иначе сообщение об ошибке предыдущего
|
||||
// выбора (например, «Ошибка чтения архива») останется висеть в интерфейсе.
|
||||
if (elements.statusEl) elements.statusEl.textContent = '';
|
||||
try {
|
||||
await addFiles(state, cfg, elements.fileInputEl.files, elements);
|
||||
} finally {
|
||||
// Даже исключение вне внутреннего ZIP-catch не оставляет picker заблокированным.
|
||||
elements.fileInputEl.value = '';
|
||||
state.busy = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,58 +1,107 @@
|
||||
// Обработчик <input webkitdirectory> change: выбор целой папки, рекурсивно,
|
||||
// относительный путь сохраняется (верхняя папка отбрасывается).
|
||||
|
||||
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';
|
||||
import { lstFilter } from './lst_filter.js';
|
||||
|
||||
// Фабрика обработчика change для input выбора папки.
|
||||
// ctx = { state, cfg, els, onStatus }
|
||||
export function makeFolderChangeHandler(ctx) {
|
||||
const { state, cfg, els, onStatus } = ctx;
|
||||
/**
|
||||
* Создаёт 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; // во время загрузки/обработки менять список нельзя
|
||||
const incoming = Array.from(els.folderInput.files);
|
||||
if (!incoming.length) return;
|
||||
document.body.style.cursor = 'wait';
|
||||
if (onStatus) onStatus('progress', 'Разбираю папку…');
|
||||
let added = 0;
|
||||
if (state.busy) return;
|
||||
state.busy = true;
|
||||
// Сброс статуса перед новым выбором: иначе сообщение об ошибке предыдущего
|
||||
// выбора (например, «Ошибка чтения архива») останется висеть в интерфейсе.
|
||||
if (elements.statusEl) elements.statusEl.textContent = '';
|
||||
try {
|
||||
for (const f of incoming) {
|
||||
// webkitRelativePath: "TopFolder/Подпапка/file.pdf" — отбрасываем верхнюю папку
|
||||
const parts = (f.webkitRelativePath || f.name).split('/');
|
||||
const rel = parts.slice(1).join('/') || f.name;
|
||||
const low = rel.toLowerCase();
|
||||
const slashIdx = rel.lastIndexOf('/');
|
||||
const relDir = slashIdx >= 0 ? rel.slice(0, slashIdx) : '';
|
||||
if (low.endsWith('.zip')) {
|
||||
try {
|
||||
const nested = await listZipFiles(f, cfg.allowedExt);
|
||||
if (nested.length) {
|
||||
for (const nf of nested) {
|
||||
const nm = relDir ? relDir + '/' + nf.name : nf.name;
|
||||
if (addFileWithDedup(state, new File([nf], nm, { lastModified: nf.lastModified }), cfg)) added++;
|
||||
}
|
||||
} else {
|
||||
// в архиве нет документов — добавить архив как есть, чтобы не терялся
|
||||
if (addFileWithDedup(state, new File([f], rel, { lastModified: f.lastModified }), cfg)) added++;
|
||||
}
|
||||
} catch (err) {
|
||||
if (addFileWithDedup(state, new File([f], rel, { lastModified: f.lastModified }), cfg)) added++; // zip как есть
|
||||
}
|
||||
} else if (cfg.allowedExt.some(e => low.endsWith(e))) {
|
||||
if (addFileWithDedup(state, new File([f], rel, { lastModified: f.lastModified }), cfg)) added++;
|
||||
const roots = new Map();
|
||||
// Manifest-фильтр: при наличии корневого `obscure.lst` набор файлов
|
||||
// ограничивается его правилами, а сам manifest в дерево не попадает.
|
||||
// Без manifest модуль возвращает исходный список без изменений.
|
||||
const filtered = await lstFilter(elements.folderInputEl.files, [...cfg.allowedExt, '.zip']);
|
||||
if (filtered.manifest) {
|
||||
// Диагностику показываем в statusEl: ошибки строк и предупреждения правил.
|
||||
const messages = filtered.diagnostics.map((item) => (
|
||||
item.line ? `строка ${item.line}: ${item.message}` : item.message
|
||||
));
|
||||
if (messages.length && elements.statusEl) {
|
||||
elements.statusEl.textContent = messages.join('; ');
|
||||
}
|
||||
// иначе — не документ, пропускаем
|
||||
}
|
||||
for (const file of filtered.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 {
|
||||
document.body.style.cursor = '';
|
||||
}
|
||||
els.folderInput.value = ''; // чтобы повторный выбор той же папки сработал
|
||||
render(state, els, cfg);
|
||||
if (added && onStatus) {
|
||||
const n = added;
|
||||
const w = (n % 10 === 1 && n % 100 !== 11) ? 'файл' : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 'файла' : 'файлов';
|
||||
onStatus('done', '✅ Добавлено из папки: ' + n + ' ' + w);
|
||||
state.busy = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// procRow — строка таблицы обработки (3-секционная).
|
||||
|
||||
import { esc } from './esc.js';
|
||||
import { fs } from './fs.js';
|
||||
|
||||
export function procRow(state, i, stTxt) {
|
||||
const f = state.files[i];
|
||||
const over = state.overNames.has(f.name);
|
||||
const p = state.proc && state.proc.procState ? state.proc.procState[i] : null;
|
||||
const cls = (p && p.st === 'current') ? ' class="row-current"'
|
||||
: (over ? ' class="row-over"' : '');
|
||||
return '<tr' + cls + '><td class="name-cell">' + esc(f.name) + '</td><td class="num-cell">' + fs(f.size) + '</td><td class="num-cell" style="font-size:12px;">' + stTxt + '</td><td></td></tr>';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+153
-25
@@ -1,29 +1,157 @@
|
||||
// render — рендер таблицы выбора файлов (обычная).
|
||||
// Если идёт обработка (state.proc.phase === 'processing') — 3-секционная.
|
||||
|
||||
import { esc } from './esc.js';
|
||||
import { fs } from './fs.js';
|
||||
import { fmtSec } from './fmt_sec.js';
|
||||
import { estForFile } from './est_for_file.js';
|
||||
import { renderProcTable } from './render_proc_table.js';
|
||||
|
||||
export function render(state, els, cfg) {
|
||||
if (state.proc && state.proc.phase === 'processing') { renderProcTable(state, els, cfg); return; }
|
||||
if (state.files.length === 0) {
|
||||
els.tableBody.innerHTML = '<tr class="empty-row"><td colspan="4">Нет выбранных файлов</td></tr>';
|
||||
} else {
|
||||
els.tableBody.innerHTML = state.files.map((f, i) => {
|
||||
const over = state.overNames.has(f.name);
|
||||
const rowCls = over ? ' class="row-over"' : '';
|
||||
const stTxt = over ? '<span style="color:#c0392b;">🔥 не учитывается</span>'
|
||||
: '<span style="color:#7d3c98;">~' + fmtSec(estForFile(f, cfg.estMbSec)) + '</span>';
|
||||
return '<tr id="row-' + i + '"' + rowCls + '><td class="name-cell">' + esc(f.name) + '</td><td class="num-cell">' + fs(f.size) + '</td><td class="num-cell" id="st-' + i + '" style="font-size:12px;">' + stTxt + '</td><td><button class="remove-btn" data-idx="' + i + '">✕</button></td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
const overCount = state.files.filter(f => state.overNames.has(f.name)).length;
|
||||
const totalSize = state.files.reduce((s, f) => s + (state.overNames.has(f.name) ? 0 : f.size), 0);
|
||||
const cntMain = state.files.length - overCount;
|
||||
const totEst = state.files.reduce((s, f) => s + (state.overNames.has(f.name) ? 0 : estForFile(f, cfg.estMbSec)), 0);
|
||||
els.countEl.textContent = (overCount ? cntMain + ' учитываются + ' + overCount + ' свыше лимита' : state.files.length) + ' файлов · ' + fs(totalSize) + ' · ~' + fmtSec(totEst);
|
||||
els.uploadBtnEl.disabled = cntMain === 0;
|
||||
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 `<span class="fp-status-${kind}">${esc(status.text || cfg.labels.statusReady)}</span>`;
|
||||
}
|
||||
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 `<span class="fp-status-uploading">${uploading} загружается/обрабатывается</span>`;
|
||||
if (errors) return `<span class="fp-status-error">${errors} ошибок</span>`;
|
||||
if (parsed === statuses.length) return `<span class="fp-status-parsed">${statuses.length} готово</span>`;
|
||||
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
|
||||
? `<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 documentId = state.documents.get(node.id);
|
||||
const details = node.kind === 'file' && documentId
|
||||
? `<button class="fp-action" type="button" data-details="${esc(node.id)}" aria-label="${esc(cfg.labels.details)} ${name}" title="${esc(cfg.labels.details)}">Детали</button>`
|
||||
+ `<button class="fp-action" type="button" data-text="${esc(node.id)}" aria-label="${esc(cfg.labels.text)} ${name}" title="${esc(cfg.labels.text)}">Текст</button>`
|
||||
: '';
|
||||
const remove = `<button class="remove-btn" type="button" data-remove="${esc(node.id)}" aria-label="${esc(cfg.labels.remove)} ${name}">×</button>`;
|
||||
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 = `<tr class="tree-row tree-${node.kind}"${pathAttribute}${fileIdAttribute}>`
|
||||
+ columns.map((column) => `<td>${cells[column] || ''}</td>`).join('')
|
||||
+ `<td class="fp-actions">${details}${remove}</td></tr>`;
|
||||
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('')
|
||||
: `<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<{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;
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
// renderProcTable — 3-секционная таблица во время обработки
|
||||
// (готово / текущий / ожидают / пропущены сверх лимита).
|
||||
|
||||
import { fmtSec } from './fmt_sec.js';
|
||||
import { estForFile, DEFAULT_EST_MB_SEC } from './est_for_file.js';
|
||||
import { procRow } from './proc_row.js';
|
||||
|
||||
export function renderProcTable(state, els, cfg) {
|
||||
const procState = state.proc.procState;
|
||||
const groups = { done: [], current: [], pending: [], over: [] };
|
||||
for (let i = 0; i < state.files.length; i++) {
|
||||
if (state.overNames.has(state.files[i].name)) { groups.over.push(i); continue; }
|
||||
const st = procState[i] ? procState[i].st : 'pending';
|
||||
if (st === 'done' || st === 'skipped') groups.done.push(i);
|
||||
else if (st === 'current') groups.current.push(i);
|
||||
else groups.pending.push(i);
|
||||
}
|
||||
// Оценка скорости из текущего файла (сек/символ) — для «ожидающих»
|
||||
let rate = null;
|
||||
for (const i of groups.current) {
|
||||
const p = procState[i];
|
||||
const cur = (performance.now() - p.t0) / 1000;
|
||||
const total = cur + (p.eta != null ? p.eta : 0);
|
||||
if (p.chars > 0 && total > 0) rate = total / p.chars;
|
||||
}
|
||||
const rows = [];
|
||||
if (groups.done.length) {
|
||||
rows.push('<tr class="grp-row"><td colspan="4">✓ Обработанные (' + groups.done.length + ')</td></tr>');
|
||||
for (const i of groups.done) {
|
||||
const p = procState[i];
|
||||
const txt = p.st === 'skipped'
|
||||
? '<span style="color:#c0392b;" title="Не удалось прочитать файл: пустой, повреждённый или скан без текста">не извлечён</span>'
|
||||
: '<span style="color:#22c55e;">✓ ' + (p.elapsed ? p.elapsed.toFixed(1) : '0.0') + 'с</span>';
|
||||
rows.push(procRow(state, i, txt));
|
||||
}
|
||||
}
|
||||
if (groups.over.length) {
|
||||
rows.push('<tr class="grp-row"><td colspan="4">⛔ Пропущены (сверх лимита) (' + groups.over.length + ')</td></tr>');
|
||||
for (const i of groups.over) {
|
||||
rows.push(procRow(state, i, '<span style="color:#c0392b;">пропущен (лимит)</span>'));
|
||||
}
|
||||
}
|
||||
if (groups.current.length) {
|
||||
rows.push('<tr class="grp-row"><td colspan="4">▶ Текущий файл</td></tr>');
|
||||
for (const i of groups.current) {
|
||||
const p = procState[i];
|
||||
const cur = ((performance.now() - p.t0) / 1000).toFixed(1);
|
||||
const eta = (p.eta != null) ? ' / ~' + fmtSec(p.eta) : '';
|
||||
rows.push(procRow(state, i, '<span style="color:#2563eb;">⏳ ' + cur + 'с' + eta + '</span>'));
|
||||
}
|
||||
}
|
||||
if (groups.pending.length) {
|
||||
rows.push('<tr class="grp-row"><td colspan="4">○ Ожидают обработки (' + groups.pending.length + ')</td></tr>');
|
||||
for (const i of groups.pending) {
|
||||
const p = procState[i] || {};
|
||||
// Оценка: LLM (chars x rate) если известна; иначе грубая по размеру/скорости извлечения
|
||||
if (rate && !p.est && p.chars > 0) p.est = p.chars * rate;
|
||||
if (!p.est) {
|
||||
const szMB = (state.files[i] ? state.files[i].size : 0) / 1048576;
|
||||
const k = (state.proc && state.proc.procExtractRate) || cfg.estMbSec || DEFAULT_EST_MB_SEC; // сек/МБ
|
||||
p.est = Math.max(1, Math.round(szMB * k));
|
||||
}
|
||||
let txt;
|
||||
if (p.st === 'analyzed') txt = '<span style="color:#7d3c98;">анализ ✓</span>';
|
||||
else if (p.st === 'current') txt = '<span style="color:#2563eb;">⏳</span>';
|
||||
else if (p.est != null) txt = '<span style="color:#999;">~' + fmtSec(p.est) + '</span>';
|
||||
else txt = '<span style="color:#999;">—</span>';
|
||||
rows.push(procRow(state, i, txt));
|
||||
}
|
||||
}
|
||||
els.tableBody.innerHTML = rows.join('');
|
||||
const cntDone = groups.done.length;
|
||||
els.countEl.textContent = 'Готово ' + cntDone + ' / ' + state.files.length + ' файлов';
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
// rmFile — удалить файл из списка (если не busy).
|
||||
|
||||
import { render } from './render.js';
|
||||
|
||||
export function rmFile(state, els, cfg, i) {
|
||||
if (state.busy) return;
|
||||
const nm = state.files[i].name;
|
||||
state.overNames.delete(nm);
|
||||
state.fileMeta.delete(nm);
|
||||
state.files.splice(i, 1);
|
||||
// Синхронизировать input.files (DataTransfer) — чтобы повторный выбор того же
|
||||
// файла сработал (change не сработает, если files не обновлён).
|
||||
if (els.fileInput && els.fileInput.files) {
|
||||
const d = new DataTransfer();
|
||||
state.files.forEach(f => d.items.add(f));
|
||||
els.fileInput.files = d.files;
|
||||
}
|
||||
render(state, els, cfg);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// setStatus — записать HTML-статус в ячейку таблицы (st-<idx>).
|
||||
|
||||
export function setStatus(idx, html) {
|
||||
const e = document.getElementById('st-' + idx);
|
||||
if (e) e.innerHTML = html;
|
||||
}
|
||||
Reference in New Issue
Block a user