Integrate Obfuscator file picker into Contracts
Deploy contracts-flask / validate (push) Canceled after 0s

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Repinoid
2026-10-30 10:24:23 +03:00
co-authored by Copilot
parent 83002b9644
commit 6e6fc4db8e
41 changed files with 3746 additions and 1485 deletions
+8 -6
View File
@@ -21,13 +21,15 @@
## 2. Загрузка файлов ## 2. Загрузка файлов
Фронтенд начинает обработку в [`site/static/files.js`](../site/static/files.js): Выбор файлов реализован в [`upload/frontend/index.js`](../upload/frontend/index.js) и модулях `table/` и `zip/`; собранный IIFE подключается на странице через `/file-picker/file-picker.iife.js`. Picker строит дерево, раскрывает ZIP, а при выборе папки применяет корневой `obscure.lst`.
- `onFilesSelected()` принимает выбранные файлы, обрабатывает ZIP и обычные документы, запускает подготовку загрузки и сохраняет связь `zip_source`. Связка с приложением находится в [`site/static/app.js`](../site/static/app.js) и [`site/static/files.js`](../site/static/files.js):
- `uploadFile()` запускает загрузку через VM-буфер; фактические PUT и отправка ссылок выполняются функциями `uploadViaVM()` и `putToVm()` в [`upload/frontend/upload/upload_via_vm.js`](../upload/frontend/upload/upload_via_vm.js) и [`upload/frontend/upload/put_to_vm.js`](../upload/frontend/upload/put_to_vm.js).
- `finalizeUpload()` завершает загрузку и обновляет состояние. - `onPickerFilesChange()` синхронизирует leaf-файлы дерева с `state.files`, вычисляет `zip_source` как путь до последнего ZIP в цепочке и ставит новые файлы в последовательную очередь.
- `renderFiles(state)` отображает список файлов, группируя вложения по `zip_source`. - `uploadPickerEntry()` вызывает `FilePicker.uploadViaVM()` для одного файла: PUT в VM-буфер, затем POST `/api/upload_refs` с `batch_id`, `contract_id` и `zip_source`.
- `syncDB()` синхронизирует состав файлов в БД. - Результат sink (`doc_id`, `parsed` или duplicate/error) отображается в строке дерева; детали и распарсенный текст доступны из действий строки.
- `finalizeUpload()` ожидает завершения очереди и синхронизации, затем включает классификацию.
- `syncDB()` синхронизирует только текущий `batch_id`; перед удалением документа ждёт активную очередь загрузки.
Из-за ограничения managed-шлюза большие файлы сначала передаются на ВМ, а затем бэкенд забирает их исходящим запросом. Общий переиспользуемый транспорт находится в [`upload/backend/upload_refs/blueprint.py`](../upload/backend/upload_refs/blueprint.py), включая обработчик `POST /api/upload_refs`; конкретная бизнес-логика передаётся через `sink`. Из-за ограничения managed-шлюза большие файлы сначала передаются на ВМ, а затем бэкенд забирает их исходящим запросом. Общий переиспользуемый транспорт находится в [`upload/backend/upload_refs/blueprint.py`](../upload/backend/upload_refs/blueprint.py), включая обработчик `POST /api/upload_refs`; конкретная бизнес-логика передаётся через `sink`.
+16 -12
View File
@@ -34,17 +34,9 @@ def transaction():
def init_db(): def init_db():
"""Создать/пересоздать БД + схему. Вызывается при старте и после cleanup.""" """Create the database schema without deleting persisted data."""
global _db_session_key global _db_session_key
_db_session_key = time.time() _db_session_key = time.time_ns()
# Удалить старый файл + WAL-сателлиты
for f in (DB_PATH, DB_PATH + "-wal", DB_PATH + "-shm"):
try:
os.remove(f)
except FileNotFoundError:
pass
conn = get_conn() conn = get_conn()
_create_schema(conn) _create_schema(conn)
return conn return conn
@@ -147,8 +139,20 @@ def _create_schema(conn):
def cleanup_db(): def cleanup_db():
"""Удалить БД полностью. Вызывает init_db() для создания новой.""" """Explicitly delete the database and recreate an empty schema."""
init_db() global _db_session_key
conn = getattr(_local, "conn", None)
if conn is not None:
conn.close()
_local.conn = None
_local.session_key = None
_db_session_key = time.time_ns()
for path in (DB_PATH, DB_PATH + "-wal", DB_PATH + "-shm"):
try:
os.remove(path)
except FileNotFoundError:
pass
return init_db()
def get_conn(): def get_conn():
+44 -36
View File
@@ -1,7 +1,7 @@
"""API blueprint — groups, documents, supplements, sync, cleanup, spec-current, chat.""" """API blueprint — groups, documents, supplements, sync, cleanup, spec-current, chat."""
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from db import documents, supplements, spec_current from db import documents, supplements, spec_current
from db.connection import execute, query from db.connection import execute, query, transaction
from services.grouping import group_documents, apply_groups from services.grouping import group_documents, apply_groups
from config import LLM_URL, LLM_KEY, LLM_MODEL from config import LLM_URL, LLM_KEY, LLM_MODEL
import httpx import httpx
@@ -38,47 +38,65 @@ def api_document(doc_id):
@api_bp.route("/api/documents/<doc_id>", methods=["DELETE"]) @api_bp.route("/api/documents/<doc_id>", methods=["DELETE"])
def api_document_delete(doc_id): def api_document_delete(doc_id):
if not documents.get(doc_id):
return jsonify(ok=False, error="not found"), 404
with transaction():
contract_ids = _delete_document(doc_id)
_delete_orphan_contracts(contract_ids)
return jsonify(ok=True)
def _delete_document(doc_id):
supps = query("SELECT id, contract_id FROM supplements WHERE document_id = %s", (doc_id,)) supps = query("SELECT id, contract_id FROM supplements WHERE document_id = %s", (doc_id,))
for s in (supps or []): contract_ids = {row["contract_id"] for row in supps}
for supp in supps:
execute( execute(
"""DELETE FROM spec_current WHERE contract_id = %s """DELETE FROM spec_current WHERE contract_id = %s
AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)""", AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)""",
(s["contract_id"], s["id"]), (supp["contract_id"], supp["id"]),
) )
execute("DELETE FROM spec_events WHERE supplement_id = %s", (s["id"],)) execute("DELETE FROM spec_events WHERE supplement_id = %s", (supp["id"],))
execute("DELETE FROM supplements WHERE id = %s", (s["id"],)) execute("DELETE FROM supplements WHERE id = %s", (supp["id"],))
execute("DELETE FROM documents WHERE id = %s", (doc_id,)) execute("DELETE FROM documents WHERE id = %s", (doc_id,))
return jsonify(ok=True) return contract_ids
def _delete_orphan_contracts(contract_ids):
for contract_id in contract_ids:
remaining = query(
"SELECT id FROM supplements WHERE contract_id = %s LIMIT 1",
(contract_id,),
)
if not remaining:
execute("DELETE FROM contracts WHERE id = %s", (contract_id,))
# ── Sync ───────────────────────────────────────────────────────── # ── Sync ─────────────────────────────────────────────────────────
@api_bp.route("/api/sync", methods=["POST"]) @api_bp.route("/api/sync", methods=["POST"])
def api_sync(): def api_sync():
"""Удалить документы, не входящие в keep_ids.""" """Remove documents absent from keep_ids in the specified batch only."""
body = request.get_json() body = request.get_json(silent=True) or {}
keep_ids = set(body.get("keep_ids", [])) batch_id = body.get("batch_id")
keep_ids = body.get("keep_ids", [])
if not isinstance(batch_id, str) or not batch_id.strip():
return jsonify(ok=False, error="batch_id required"), 400
if not isinstance(keep_ids, list) or any(not isinstance(doc_id, str) for doc_id in keep_ids):
return jsonify(ok=False, error="keep_ids must be a list of document IDs"), 400
if len(keep_ids) > 1000: if len(keep_ids) > 1000:
return jsonify(ok=False, error="too many keep_ids (max 1000)"), 400 return jsonify(ok=False, error="too many keep_ids (max 1000)"), 400
docs = query("SELECT id FROM documents", ()) docs = query("SELECT id FROM documents WHERE batch_id = %s", (batch_id,))
deleted = 0 deleted = 0
for d in (docs or []): contract_ids = set()
if d["id"] in keep_ids: keep_ids = set(keep_ids)
continue with transaction():
supps = query("SELECT id, contract_id FROM supplements WHERE document_id = %s", (d["id"],)) for doc in docs:
for s in (supps or []): if doc["id"] in keep_ids:
execute( continue
"""DELETE FROM spec_current WHERE contract_id = %s contract_ids.update(_delete_document(doc["id"]))
AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)""", deleted += 1
(s["contract_id"], s["id"]), _delete_orphan_contracts(contract_ids)
)
execute("DELETE FROM spec_events WHERE supplement_id = %s", (s["id"],))
execute("DELETE FROM supplements WHERE id = %s", (s["id"],))
execute("DELETE FROM documents WHERE id = %s", (d["id"],))
deleted += 1
execute("DELETE FROM contracts WHERE id NOT IN (SELECT DISTINCT contract_id FROM supplements)")
return jsonify(ok=True, deleted=deleted) return jsonify(ok=True, deleted=deleted)
@@ -170,13 +188,3 @@ def chat():
return jsonify(OK=True, ANSWER=answer) return jsonify(OK=True, ANSWER=answer)
except Exception as e: except Exception as e:
return jsonify(OK=False, ERROR=f"LLM error: {e}") return jsonify(OK=False, ERROR=f"LLM error: {e}")
# ── Cleanup (атомарное удаление БД) ──────────────────────────────
@api_bp.route("/api/cleanup", methods=["POST"])
def api_cleanup():
"""Полная очистка: os.remove(DB) + init новой. Данные гарантированно стёрты."""
from db.connection import cleanup_db
cleanup_db()
return jsonify(ok=True, message="all data cleaned")
+10
View File
@@ -9,6 +9,10 @@ _UPLOAD_FRONTEND = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"upload", "frontend", "upload", "frontend",
) )
_FILE_PICKER_DIST = os.path.join(
os.path.dirname(os.path.dirname(_UPLOAD_FRONTEND)),
"site", "static", "dist",
)
@pages_bp.route("/") @pages_bp.route("/")
@@ -25,3 +29,9 @@ def architect():
def upload_frontend(filename): def upload_frontend(filename):
"""Отдать статику ES-модулей upload/frontend (для клиентского ZIP).""" """Отдать статику ES-модулей upload/frontend (для клиентского ZIP)."""
return send_from_directory(_UPLOAD_FRONTEND, filename) return send_from_directory(_UPLOAD_FRONTEND, filename)
@pages_bp.route("/file-picker/<path:filename>")
def file_picker_bundle(filename):
"""Отдать собранный picker bundle."""
return send_from_directory(_FILE_PICKER_DIST, filename)
+25 -42
View File
@@ -1,45 +1,51 @@
// ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔ // ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔
// Contracts App v2.0 — всё на Flask, ВМ больше нет // Contracts App v2.0 — backend на Flask; ВМ используется только как upload buffer.
var VM_API = ''; var VM_API = '';
// ВМ-буфер загрузки (паттерн drhider): браузер кладёт файл сюда (WebDAV, мимо шлюза), // ВМ-буфер загрузки (паттерн drhider): браузер кладёт файл сюда (WebDAV, мимо шлюза),
// бэк сам тянет его по /api/upload_refs. Origin должен быть в CORS на nginx ВМ. // бэк сам тянет его по /api/upload_refs. Origin должен быть в CORS на nginx ВМ.
var VM_UPLOAD_URL = 'https://contracts.kube5s.ru/contracts-upload/'; var VM_UPLOAD_URL = 'https://contracts.kube5s.ru/contracts-upload/';
// SITE_URL удалён (Фаза 4) — не использовался // SITE_URL удалён (Фаза 4) — не использовался
var fileInput = document.getElementById('fileInput');
var fileTable = document.getElementById('fileTable');
// state.files — теперь в state.js (Фаза 0: state + render) // state.files — теперь в state.js (Фаза 0: state + render)
// state.contractId — теперь в state.js (Фаза 0: state + render) // state.contractId — теперь в state.js (Фаза 0: state + render)
// state.batchId — теперь в state.js (Фаза 0: state + render) // state.batchId — теперь в state.js (Фаза 0: state + render)
// (batchId = crypto.randomUUID() — уникальный ID сессии для классификации) // (batchId = crypto.randomUUID() — уникальный ID сессии для классификации)
// Прогрев upstream + автоочистка при загрузке страницы // Прогрев приложения при загрузке страницы.
// ⚠️ Без прогрева первый POST может упасть с ERR_CONNECTION_RESET (MTU/Geneve) // ⚠️ Без прогрева первый POST может упасть с ERR_CONNECTION_RESET (MTU/Geneve)
(async function init() { (async function init() {
try { await fetch('/health'); } catch(e) { /* ignore */ } try { await fetch('/health'); } catch(e) { /* ignore */ }
try { await fetch(VM_API + '/api/cleanup', { method: 'POST' }); } catch(e) { /* ignore */ }
})(); })();
/** /**
* render(state) — Главная функция рендеринга (Фаза 0, decoupling-final-plan.md). * Обновить отображение picker и текущий шаг pipeline после изменения state.
*
* ПАТТЕРН: action → мутация state → render(state)
*
* Фаза 0: вызывает существующий renderTable().
* renderTable читает state.files напрямую (state — глобальный объект).
* Фаза 1+: будет вызывать renderFiles(state), renderGroups(state), renderStepper(state) и т.д.
*
* ПРАВИЛО: любое изменение state ДОЛЖНО завершаться вызовом render(state).
* Никакой прямой манипуляции DOM в обход render().
* console.log(state) показывает ВСЁ состояние в любой момент.
*/ */
function render(state) { function render(state) {
renderFiles(state); renderFiles(state);
renderStepper(state); renderStepper(state);
} }
// syncDB, statusToHTML, renderFiles, toggleClassifyDetail, uploadFile, refreshSupps if (!window.FilePicker) throw new Error('Не загружен бандл FilePicker');
// → вынесены в files.js (Фаза 1) filePicker = window.FilePicker.initFilePicker({
mount: '#filePicker',
allowedExt: ['.pdf', '.doc', '.docx'],
limits: FILE_PICKER_LIMITS,
onChange: onPickerFilesChange,
onError: function(error, file) {
filePicker.setMessage('Ошибка чтения ' + file.name + ': ' + error.message);
},
onDetails: function(id) {
var index = state.files.findIndex(function(file) { return file.pickerId === id; });
if (index >= 0) toggleClassifyDetail(index);
},
onText: function(id) {
var index = state.files.findIndex(function(file) { return file.pickerId === id; });
if (index >= 0) showText(index);
}
});
// Picker selection, upload queue, syncDB, document details and finalization
// are wired by files.js.
// ── Pipeline stepper (Фаза 4: state-driven) ────────────────── // ── Pipeline stepper (Фаза 4: state-driven) ──────────────────
@@ -97,36 +103,13 @@ function resetStepper(fromId) {
} }
// Утилиты (formatSize, formatDate, moveUp, moveDown, escHtml, fmtDate) — // Утилиты (formatSize, formatDate, moveUp, moveDown, escHtml, fmtDate) —
// вынесены в app_utils.js для облегчения анализа и отладки. // вынесены в app_utils.js для облегчения анализа и отладки.
// statusToHTML, renderFiles → files.js (Фаза 1) // Picker rows are rendered by the FilePicker integration in files.js.
// ── Раскрыть/скрыть результат классификации под строкой файла ── // ── Раскрыть/скрыть результат классификации под строкой файла ──
// toggleClassifyDetail → files.js (Фаза 1) // toggleClassifyDetail → files.js (Фаза 1)
// moveUp/moveDown/removeFile/openAbout/closeAbout — вынесены в app_utils.js // moveUp/moveDown/removeFile/openAbout/closeAbout — вынесены в app_utils.js
// ── Автозагрузка при выборе файлов ──────────────────────────
// Фаза 1 (decoupling-final-plan.md): fileInput handler разбит на чистые функции
// onFilesSelected → reconcileSelection → addZipFile/addRegularFile → finalizeUpload
// Вся логика — в files.js. Здесь только тонкая обёртка.
fileInput.addEventListener('change', async function() {
var newFiles = Array.from(fileInput.files);
onFilesSelected(newFiles);
});
// Выбор папки (webkitdirectory) — та же обработка, что и файлы
var folderInput = document.getElementById('folderInput');
var folderBtn = document.getElementById('folderBtn');
if (folderBtn && folderInput) {
folderBtn.addEventListener('click', function() { folderInput.click(); });
folderInput.addEventListener('change', function() {
var files = Array.from(folderInput.files).filter(function(f) {
var n = f.name.toLowerCase();
return n.endsWith('.pdf') || n.endsWith('.doc') || n.endsWith('.docx') || n.endsWith('.zip');
});
if (files.length) onFilesSelected(files);
});
}
// ── Классификация ────────────────────────────────────────── // ── Классификация ──────────────────────────────────────────
function showClassifyBtn() { function showClassifyBtn() {
var btn = document.getElementById('classifyBtn'); var btn = document.getElementById('classifyBtn');
+4 -11
View File
@@ -6,7 +6,7 @@
/** /**
* Форматирует размер файла в человекочитаемый вид. * Форматирует размер файла в человекочитаемый вид.
* Используется в renderTable() для колонки "Размер". * Используется в модальном окне документа.
*/ */
function formatSize(bytes) { function formatSize(bytes) {
if (!bytes || bytes === 0) return '—'; if (!bytes || bytes === 0) return '—';
@@ -17,7 +17,7 @@ function formatSize(bytes) {
/** /**
* Форматирует timestamp в российскую дату + время. * Форматирует timestamp в российскую дату + время.
* Используется в renderTable() для колонки "Изменён". * Используется в модальном окне документа.
*/ */
function formatDate(ts) { function formatDate(ts) {
if (!ts) return '—'; if (!ts) return '—';
@@ -33,15 +33,8 @@ function formatDate(ts) {
* Если очередь опустела — сбрасывает state.contractId. * Если очередь опустела — сбрасывает state.contractId.
*/ */
function removeFile(i) { function removeFile(i) {
state.files.splice(i, 1); var file = state.files[i];
if (state.files.length === 0) state.contractId = null; if (file && filePicker) filePicker.remove(file.pickerId);
render(state);
// Синхронизировать БД с таблицей
if (typeof syncDB === 'function') syncDB();
// Сбросить прогресс при изменении состава файлов
if (typeof resetStepper === 'function') resetStepper('stepUpload');
// Разблокировать кнопку классификации при изменении состава файлов
if (typeof showClassifyBtn === 'function') showClassifyBtn();
} }
window.removeFile = removeFile; window.removeFile = removeFile;
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Arjun Barrett
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because it is too large Load Diff
+254 -265
View File
@@ -3,134 +3,48 @@
* *
* ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔⛔⛔ * ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔⛔⛔
* *
* v2.0.3: честный счётчик ⏳ соединение... Nс вместо фейкового ↑N% * FilePicker owns selection/tree rendering; this module connects it to the
* Проверено в бою 2026-07-16. Любое изменение = риск сломать загрузку. * Contracts batch, upload sink, and document pipeline.
* *
* КЛЮЧЕВЫЕ ФУНКЦИИ (не трогать): * Состав и обработка документов синхронизируются с FilePicker в app.js.
* - uploadFile() — fetch-загрузка с имитацией прогресса
* - onFilesSelected() — все строки в таблицу сразу, потом загрузка по одной
* - statusToHTML() — рендер статуса (↑ N%, ✓)
* - renderFiles() — рендер всей таблицы
/**
* statusToHTML(status) — Чистая функция: структура → HTML (Фаза 1).
*
* Вход: { kind, pct?, text?, count?, elapsed? } — ни одного HTML-тега.
* kind = 'uploading' | 'uploaded' | 'unzipping' | 'parsing' | 'parsed' | 'error' | '' | 'connecting'
* Выход: безопасная HTML-строка (статусы не содержат пользовательских данных).
*
* ПАТТЕРН (decoupling-final-plan.md): отделяем данные от представления.
* state хранит чистые данные, statusToHTML — чистая функция рендеринга.
*/ */
function statusToHTML(st) {
if (!st || !st.kind) return ''; var filePicker = null;
switch (st.kind) { var uploadQueue = Promise.resolve();
case 'connecting': return '⏳ соединение... ' + (st.elapsed || 0) + 'с'; var uploadTasks = new Map();
case 'converting': return '⏳ конвертация... ' + (st.elapsed || 0) + 'с'; var queuedUploads = new Set();
case 'uploading': return '⏳ отправка... ' + (st.elapsed || 0) + 'с'; var pendingUploads = 0;
case 'uploaded': return '<span class="status-ok">✓</span>'; var FILE_PICKER_LIMITS = {
case 'unzipping': return '⏳ распаковка...'; maxEntries: 1000,
case 'parsing': return '⏳ парсинг...'; maxTotalBytes: 500 * 1024 * 1024,
case 'parsed': return '<span class="status-ok">✓ ' + (st.count || 0) + ' эл.' + (st.elapsed ? ' (' + st.elapsed + 'с)' : '') + '</span>'; maxEntryBytes: 50 * 1024 * 1024,
case 'error': return '<span class="status-err">✗ ' + escHtml(st.text || 'Неизвестная ошибка') + '</span>'; maxDepth: 20,
case 'duplicate': return '<span style="color:var(--muted);">⤿ дубликат</span>'; };
case 'warning': return '<span style="color:#eab308;">⚠ ' + escHtml(st.text || 'пустой') + '</span>';
case 'no_format': return '<span class="status-err">✗ формат не поддерживается</span>';
default: return '';
}
}
/** /**
* renderFiles(state) — Рендер таблицы файлов (бывший renderTable, Фаза 1). * renderFiles(state) — обновить отображение дерева и статусов picker.
*
* Читает state.files, генерирует HTML для #fileTable.
* Использует statusToHTML() для рендеринга статусов (чистые данные → HTML).
* Вызывает lucide.createIcons() для иконок.
*
* Вход: state (весь объект состояния, но читает только state.files).
* Выход: мутирует DOM (fileTable.innerHTML).
*/ */
function renderFiles(state) { function renderFiles(state) {
if (state.files.length === 0) { if (filePicker) filePicker.render();
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .doc / .docx / .pdf</td></tr>';
lucide.createIcons();
return;
}
// Сгруппировать файлы по zip_source
var groups = []; // [{zip: "name.zip"|null, files: [f, ...]}]
var seen = {};
for (var i = 0; i < state.files.length; i++) {
var f = state.files[i];
var zip = f.zip_source || null;
var key = zip || '__naked__';
if (!seen[key]) {
seen[key] = { zip: zip, files: [] };
groups.push(seen[key]);
}
// Сохраняем оригинальный индекс для id строк
f._idx = i;
seen[key].files.push(f);
}
// Рендер: заголовок ZIP → файлы с отступом
var rows = [];
for (var gi = 0; gi < groups.length; gi++) {
var g = groups[gi];
if (g.zip) {
// Заголовок-секция ZIP
rows.push(
'<tr class="zip-header" style="background:var(--brand-grey-light);">' +
'<td colspan="7" style="padding:6px 12px;font-size:12px;font-weight:600;">' +
'📦 ' + escHtml(g.zip) + ' — ' + g.files.length + ' файл.' +
(g.files.length === 1 ? '' : 'ов') +
'</td></tr>'
);
}
// Файлы внутри группы (с отступом если из ZIP)
for (var fi = 0; fi < g.files.length; fi++) {
var f = g.files[fi];
var i = f._idx;
var indent = g.zip ? 'padding-left:24px;' : '';
rows.push(
'<tr id="row_' + i + '">' +
'<td class="name-cell" style="cursor:pointer;' + indent + '" onclick="toggleClassifyDetail(' + i + ')">' +
(f.doc_id ? '<span id="expand_' + i + '" style="font-size:10px;margin-right:4px;">▸</span>' : '') +
escHtml(f.name) + '</td>' +
'<td style="font-size:12px;color:var(--muted);">' + formatDate(f.lastModified) + '</td>' +
'<td class="num-cell" style="font-size:12px;color:var(--muted);">' + formatSize(f.size) + '</td>' +
'<td class="status-cell">' + statusToHTML(f.status) + '</td>' +
'<td><button class="info-btn' + (f.doc_id ? ' visible' : '') + '" onclick="showText(' + i + ')" title="Текст / Распарсено"><i data-lucide="file-text" style="width:16px;height:16px;"></i></button></td>' +
'<td><button class="remove-btn" onclick="removeFile(' + i + ')" title="Удалить"><i data-lucide="trash-2" style="width:16px;height:16px;"></i></button></td>' +
'</tr>' +
'<tr id="detail_' + i + '" style="display:none;"><td colspan="7" style="padding:4px 12px;font-size:11px;background:var(--brand-grey-light);"><span style="color:var(--muted);">Загрузка...</span></td></tr>'
);
}
}
fileTable.innerHTML = rows.join('');
lucide.createIcons();
} }
/** /**
* syncDB() — Синхронизация БД с текущим состоянием файлов. * syncDB() — Синхронизация БД с текущим состоянием файлов.
* *
* Отправляет на бэкенд список doc_id, которые должны остаться в БД. * Синхронизирует keep_ids только для текущего batch_id.
* Всё, чего нет в state.files, будет удалено из БД (cascade).
* Вызывается после каждого изменения состава файлов (removeFile, upload).
*/ */
async function syncDB() { async function syncDB() {
var keepIds = state.files.map(function(f) { return f.doc_id; }).filter(Boolean); var keepIds = Array.from(new Set(state.files.map(function(f) { return f.doc_id; }).filter(Boolean)));
try { var response = await fetch(VM_API + '/api/sync', {
await fetch(VM_API + '/api/sync', { method: 'POST',
method: 'POST', headers: {'Content-Type': 'application/json'},
headers: {'Content-Type': 'application/json'}, body: JSON.stringify({batch_id: state.batchId, keep_ids: keepIds})
body: JSON.stringify({keep_ids: keepIds}) });
}); var data = await response.json();
} catch(e) { /* сервер недоступен — не критично */ } if (!response.ok || !data.ok) {
throw new Error(data.error || 'Не удалось синхронизировать документы');
}
return data;
} }
/** /**
@@ -146,7 +60,47 @@ async function syncDB() {
window.toggleClassifyDetail = async function(i) { window.toggleClassifyDetail = async function(i) {
var detailRow = document.getElementById('detail_' + i); var detailRow = document.getElementById('detail_' + i);
var expandIcon = document.getElementById('expand_' + i); var expandIcon = document.getElementById('expand_' + i);
if (!detailRow) return; if (!detailRow) {
var file = state.files[i];
if (!file || !file.doc_id) return;
document.getElementById('modalTitle').textContent = 'Классификация — ' + file.name;
document.getElementById('modalBody').innerHTML = '<span style="color:var(--muted);">Загрузка...</span>';
document.getElementById('modalOverlay').classList.add('open');
try {
var detailResponse = await fetch(VM_API + '/api/documents/' + encodeURIComponent(file.doc_id));
var detailData = await detailResponse.json();
if (!detailResponse.ok || !detailData.ok) {
throw new Error(detailData.error || 'Ошибка загрузки документа');
}
var detailHtml = '<div style="font-weight:600;margin-bottom:8px;">Результат классификации</div>';
[['Тип', detailData.doc_type], ['Номер', detailData.own_number],
['Родительский', detailData.parent_number], ['Дата', detailData.doc_date],
['Контрагент', detailData.counterparty]].forEach(function(pair) {
if (pair[1]) detailHtml += '<div class="kv"><span class="k">' + pair[0]
+ '</span><span class="v">' + escHtml(pair[1]) + '</span></div>';
});
if (detailData.classify_input) {
detailHtml += '<details><summary>Текст, отправленный в LLM</summary><pre>'
+ escHtml(detailData.classify_input) + '</pre></details>';
}
if (detailData.classify_raw) {
detailHtml += '<details><summary>Сырой ответ LLM</summary><pre>'
+ escHtml(detailData.classify_raw) + '</pre></details>';
}
if (detailData.elements_json) {
var parsedElements = typeof detailData.elements_json === 'string'
? detailData.elements_json : JSON.stringify(detailData.elements_json, null, 2);
detailHtml += '<details><summary>Распарсенные элементы</summary><pre>'
+ escHtml(parsedElements.substring(0, 5000))
+ (parsedElements.length > 5000 ? '\n... (обрезано)' : '')
+ '</pre></details>';
}
document.getElementById('modalBody').innerHTML = detailHtml;
} catch (error) {
document.getElementById('modalBody').textContent = 'Ошибка: ' + error.message;
}
return;
}
if (detailRow.style.display !== 'none') { if (detailRow.style.display !== 'none') {
detailRow.style.display = 'none'; detailRow.style.display = 'none';
@@ -204,65 +158,6 @@ window.toggleClassifyDetail = async function(i) {
} }
}; };
/**
* ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔ uploadFile — загрузка через ВМ-буфер (паттерн drhider).
*
* Фаза 1: PUT файла на ВМ (WebDAV /contracts-upload/, мимо шлюза кластера ~64КБ).
* Фаза 2: POST /api/upload_refs {files:[{name,size,url}]} — бэк сам тянет файл с ВМ.
* Честный счётчик времени: ⏳ соединение... Nс → ⏳ отправка... Nс (fetch не даёт progress).
*/
function uploadFile(file, onProgress, zipSource) {
var startTime = Date.now();
var phase = 'connecting'; // connecting → uploading
if (onProgress) onProgress({ kind: 'connecting', elapsed: 0 });
var token = crypto.randomUUID();
var vmUrl = VM_UPLOAD_URL + token + '_0';
// Честный счётчик: каждую секунду обновляем elapsed
var timer = setInterval(function() {
var elapsed = Math.floor((Date.now() - startTime) / 1000);
if (onProgress) onProgress({ kind: phase, elapsed: elapsed });
}, 1000);
// Фаза 1: PUT файла на ВМ-буфер
return fetch(vmUrl, { method: 'PUT', body: file, headers: { 'Content-Type': 'application/octet-stream' } })
.then(function(r) {
if (!r.ok) throw new Error('VM upload HTTP ' + r.status);
phase = 'uploading';
// Фаза 2: refs на бэкенд → pull с ВМ
var body = { batch_id: state.batchId, files: [{ name: file.name, size: file.size, url: vmUrl }] };
if (state.contractId) body.contract_id = state.contractId;
if (zipSource) body.zip_source = zipSource;
return fetch('/api/upload_refs?_=' + Date.now(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
})
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(function(data) {
clearInterval(timer);
if (data.ok && data.results && data.results.length === 1) {
var res = data.results[0];
if (!res.ok) throw new Error(res.error || 'Неизвестная ошибка');
var elapsed = Math.floor((Date.now() - startTime) / 1000);
if (onProgress) onProgress({ kind: 'uploading', elapsed: elapsed });
return res; // {ok, doc_id, contract_id, parsed}
}
throw new Error(data.error || 'Неизвестная ошибка');
})
.catch(function(e) {
clearInterval(timer);
if (e.message === 'Failed to fetch' || e.name === 'TypeError') {
throw new Error('Сеть');
}
throw e;
});
}
/** /**
* refreshSupps() — Обновить supplement_id/type для файлов в state.files. * refreshSupps() — Обновить supplement_id/type для файлов в state.files.
* *
@@ -291,25 +186,6 @@ async function refreshSupps() {
} catch(e) { console.log('refreshSupps error:', e); } } catch(e) { console.log('refreshSupps error:', e); }
} }
/**
* reconcileSelection(files, newFiles) — Чистая функция (Фаза 1).
*
* Согласование состава: оставляет в массиве files только те,
* имена которых есть в newFiles. Остальные удаляются.
*
* Вход: files — текущий state.files (массив)
* newFiles — FileList от <input> (массив File-объектов)
* Выход: новый массив (НЕ мутирует входной)
*
* Вызывается из onFilesSelected() ПЕРЕД обработкой каждого файла.
*/
function reconcileSelection(files, newFiles) {
var newNames = new Set(newFiles.map(function(f) { return f.name; }));
return files.filter(function(f) {
return newNames.has(f.name);
});
}
/** /**
* applyParseResult(entry, parsed, elapsed) — Чистая функция (Фаза 1). * applyParseResult(entry, parsed, elapsed) — Чистая функция (Фаза 1).
* *
@@ -338,7 +214,7 @@ function applyParseResult(entry, parsed, elapsed) {
/** /**
* finalizeUpload() — Завершение цикла загрузки (Фаза 1). * finalizeUpload() — Завершение цикла загрузки (Фаза 1).
* *
* Вызывается ПОСЛЕ обработки всех файлов в onFilesSelected(). * Вызывается после завершения очереди выбранных файлов.
* 1. Обновляет supplement_id/type через refreshSupps() * 1. Обновляет supplement_id/type через refreshSupps()
* 2. Отмечает шаг «Загрузка» как готовый * 2. Отмечает шаг «Загрузка» как готовый
* 3. Активирует шаг «Классификация» * 3. Активирует шаг «Классификация»
@@ -346,13 +222,17 @@ function applyParseResult(entry, parsed, elapsed) {
* 5. Показывает кнопки (LLM, классификация) * 5. Показывает кнопки (LLM, классификация)
*/ */
async function finalizeUpload() { async function finalizeUpload() {
if (!state.files.some(function(file) { return file.doc_id; })) {
var llmButton = document.getElementById('llmBtn');
var classifyButton = document.getElementById('classifyBtn');
if (llmButton) llmButton.style.display = 'none';
if (classifyButton) classifyButton.style.display = 'none';
return;
}
await refreshSupps(); await refreshSupps();
await syncDB();
stepDone('stepUpload'); stepDone('stepUpload');
stepActive('stepClassify'); stepActive('stepClassify');
syncDB();
fileInput.value = '';
fileInput.disabled = false;
var llmBtn = document.getElementById('llmBtn'); var llmBtn = document.getElementById('llmBtn');
llmBtn.style.display = 'flex'; llmBtn.style.display = 'flex';
@@ -361,79 +241,188 @@ async function finalizeUpload() {
lucide.createIcons(); lucide.createIcons();
} }
/** function zipSourceFromPath(path) {
* expandZipClient(f) — клиентское рекурсивное раскрытие ZIP (drhider listZipFiles). var parts = String(path || '').split('/');
* Каждый документ из архива (включая вложенные zip) добавляется в state.files var lastZip = -1;
* с zip_source = имя архива. Fallback: если раскрыть не удалось — архив как есть. parts.forEach(function(part, index) {
*/ if (part.toLowerCase().endsWith('.zip')) lastZip = index;
async function expandZipClient(f) { });
var nested = []; return lastZip < 0 ? null : parts.slice(0, lastZip + 1).join('/');
}
function isPickerEntrySelected(entry) {
return state.files.some(function(file) { return file.pickerId === entry.pickerId; });
}
function setPickerFileStatus(entry, kind, text) {
entry.status = { kind: kind, text: text };
if (filePicker) filePicker.setStatus(entry.pickerId, entry.status);
}
function showPickerError(error) {
if (filePicker) filePicker.setMessage(error.message || String(error));
}
async function uploadPickerEntry(entry) {
if (!isPickerEntrySelected(entry) || entry.uploadBlocked) return;
var task = { controller: new AbortController(), phase: 'put' };
uploadTasks.set(entry.pickerId, task);
setPickerFileStatus(entry, 'uploading', '⏳ Подготовка к загрузке');
var sinkResult;
try { try {
if (window.listZipFiles) { var response = await window.FilePicker.uploadViaVM([{
nested = await window.listZipFiles(f, ['.pdf', '.doc', '.docx']); name: entry.file.name,
size: entry.file.size,
file: entry.file,
path: entry.pickerPath
}], {
vmUploadUrl: VM_UPLOAD_URL,
backendUploadUrl: VM_API + '/api/upload_refs',
signal: task.controller.signal,
getExtraPayload: function() {
var payload = { batch_id: state.batchId };
if (state.contractId) payload.contract_id = state.contractId;
if (entry.zip_source) payload.zip_source = entry.zip_source;
return payload;
},
onFileStatus: function(index, message) {
if (message.indexOf('⚡') === 0) task.phase = 'backend';
var kind = message.indexOf('✗') === 0 ? 'error'
: message.indexOf('✓') === 0 ? 'parsing'
: task.phase === 'backend' ? 'parsing' : 'uploading';
if (isPickerEntrySelected(entry)) setPickerFileStatus(entry, kind, message);
},
onFileComplete: function(fileResult) {
sinkResult = fileResult.result;
}
});
if (!response.ok) throw new Error(response.error || 'Ошибка загрузки файла');
if (!sinkResult) throw new Error('Сервис не вернул результат обработки файла');
if (sinkResult.ok && !sinkResult.doc_id) {
throw new Error('Сервис обработал файл без идентификатора документа');
} }
} catch (e) {
nested = []; if (sinkResult.error === 'duplicate' && sinkResult.doc_id) {
} entry.doc_id = sinkResult.doc_id;
if (!nested || nested.length === 0) { entry.uploaded = true;
state.files.push({ name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: { kind: 'connecting', elapsed: 0 }, zip_source: null }); entry.status = { kind: 'duplicate', text: '⤿ дубликат' };
state.files[state.files.length - 1]._pendingFile = f; } else if (sinkResult.ok) {
return; entry.doc_id = sinkResult.doc_id;
} entry.uploaded = true;
for (var z = 0; z < nested.length; z++) { if (sinkResult.contract_id) state.contractId = sinkResult.contract_id;
state.files.push({ name: nested[z].name, lastModified: nested[z].lastModified, size: nested[z].size, file: nested[z], status: { kind: 'connecting', elapsed: 0 }, zip_source: f.name }); applyParseResult(entry, sinkResult.parsed);
state.files[state.files.length - 1]._pendingFile = nested[z]; } else {
throw new Error(sinkResult.error || 'Не удалось обработать файл');
}
if (isPickerEntrySelected(entry)) {
filePicker.setDocument(entry.pickerId, entry.doc_id);
setPickerFileStatus(entry, entry.status.kind, entry.status.text
|| (entry.status.kind === 'parsed'
? '✓ ' + (entry.status.count || 0) + ' эл.'
: entry.status.text || 'Ошибка обработки'));
}
} catch (error) {
if (!(task.controller.signal.aborted && !isPickerEntrySelected(entry))) {
setPickerFileStatus(entry, 'error', '✗ ' + error.message);
}
} finally {
uploadTasks.delete(entry.pickerId);
} }
} }
/** function enqueuePickerUpload(entry) {
* ⛔ НЕ МЕНЯТЬ ⛔ onFilesSelected — все строки сразу, потом загрузка. if (queuedUploads.has(entry.pickerId) || uploadTasks.has(entry.pickerId)) return;
* queuedUploads.add(entry.pickerId);
* v2.0.2: Фаза 1 — все строки в таблицу. Фаза 2 — загрузка по одной. pendingUploads += 1;
* Юзер видит таблицу целиком, каждая строка обновляется независимо. var run = function() { return uploadPickerEntry(entry); };
*/ uploadQueue = uploadQueue.then(run, run).finally(async function() {
async function onFilesSelected(newFiles) { queuedUploads.delete(entry.pickerId);
if (newFiles.length === 0) return; pendingUploads -= 1;
if (pendingUploads === 0) {
fileInput.disabled = true; try {
resetStepper('stepUpload'); await finalizeUpload();
} catch (error) {
// Фаза 1: ВСЕ строки в таблицу сразу showPickerError(error);
for (var i = 0; i < newFiles.length; i++) { }
var f = newFiles[i];
if (f.name.toLowerCase().endsWith('.zip')) {
await expandZipClient(f);
continue;
} }
state.files.push({ name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: { kind: 'connecting', elapsed: 0 }, zip_source: null }); });
state.files[state.files.length - 1]._pendingFile = f; }
function scheduleBatchSync() {
var sync = async function() {
await syncDB();
if (state.files.some(function(file) { return file.doc_id; })) {
showClassifyBtn();
var compareButton = document.getElementById('llmBtn');
if (compareButton) compareButton.style.display = 'flex';
}
};
uploadQueue = uploadQueue.then(sync, sync).catch(showPickerError);
}
function onPickerFilesChange(pickerFiles) {
var previous = new Map(state.files.map(function(file) { return [file.pickerId, file]; }));
var incomingIds = new Set(pickerFiles.map(function(file) { return file.id; }));
var added = [];
var nextFiles = [];
var toQueue = [];
var selectedBytes = 0;
pickerFiles.forEach(function(pickerFile) {
selectedBytes += pickerFile.size;
var entry = previous.get(pickerFile.id);
if (!entry) {
entry = {
pickerId: pickerFile.id,
pickerPath: pickerFile.path,
name: pickerFile.name,
size: pickerFile.size,
lastModified: pickerFile.file.lastModified,
file: pickerFile.file,
zip_source: zipSourceFromPath(pickerFile.path),
status: { kind: 'connecting' }
};
added.push(entry);
}
if (!entry.doc_id && !uploadTasks.has(entry.pickerId)) {
var limitError = entry.size > FILE_PICKER_LIMITS.maxEntryBytes
? 'Файл превышает лимит 50 МБ'
: selectedBytes > FILE_PICKER_LIMITS.maxTotalBytes ? 'Превышен общий лимит 500 МБ' : '';
if (limitError) {
entry.uploadBlocked = true;
entry.status = { kind: 'error', text: limitError };
filePicker.setStatus(entry.pickerId, entry.status);
} else if (entry.uploadBlocked) {
delete entry.uploadBlocked;
entry.status = { kind: 'connecting' };
if (!queuedUploads.has(entry.pickerId)) toQueue.push(entry);
} else if (added.indexOf(entry) !== -1) {
toQueue.push(entry);
}
}
nextFiles.push(entry);
});
var removed = state.files.filter(function(file) { return !incomingIds.has(file.pickerId); });
removed.forEach(function(file) {
var task = uploadTasks.get(file.pickerId);
if (task && task.phase === 'put') task.controller.abort();
});
var changed = added.length > 0 || removed.length > 0 || toQueue.length > 0;
state.files = nextFiles;
if (!state.files.some(function(file) { return file.doc_id; })) state.contractId = null;
if (changed) {
resetStepper('stepUpload');
var classifyButton = document.getElementById('classifyBtn');
if (classifyButton) classifyButton.style.display = 'none';
var compareButton = document.getElementById('llmBtn');
if (compareButton) compareButton.style.display = 'none';
} }
render(state); render(state);
// Фаза 2: загрузка по одному с обновлением строки toQueue.forEach(enqueuePickerUpload);
for (var j = 0; j < state.files.length; j++) { if (removed.length) scheduleBatchSync();
var entry = state.files[j];
var pendingFile = entry._pendingFile;
if (!pendingFile) continue;
delete entry._pendingFile;
var f = pendingFile;
try {
var resp = await uploadFile(f, function(st) {
entry.status = st;
render(state);
}, entry.zip_source);
if (resp && resp.contract_id) state.contractId = resp.contract_id;
entry.doc_id = resp.doc_id;
entry.status = { kind: 'uploaded' };
entry.uploaded = true;
var pr = resp.parsed;
var elapsed = pr && pr.status === 'parsed' ? '0.0' : null;
applyParseResult(entry, pr, elapsed);
} catch(err) {
entry.status = { kind: 'error', text: err.message };
}
render(state);
}
await finalizeUpload();
} }
+23 -19
View File
@@ -68,6 +68,26 @@
.text-pane th { font-size: 10px; padding: 4px 6px; } .text-pane th { font-size: 10px; padding: 4px 6px; }
.text-pane td { padding: 3px 6px; font-size: 11px; } .text-pane td { padding: 3px 6px; font-size: 11px; }
.text-pane .pane-title { font-weight: 600; font-size: 12px; margin-bottom: 8px; color: var(--muted); text-transform: uppercase; } .text-pane .pane-title { font-weight: 600; font-size: 12px; margin-bottom: 8px; color: var(--muted); text-transform: uppercase; }
#filePicker .file-picker { color: var(--foreground); font-family: system-ui, sans-serif; }
#filePicker .fp-toolbar { margin: 0 0 8px; }
#filePicker .fp-toolbar button { min-height: 36px; border: 1px solid var(--brand-gray); border-radius: 6px; padding: 0 14px; color: var(--foreground); background: var(--background); font: inherit; }
#filePicker .fp-toolbar button:first-of-type { color: #fff; background: var(--brand-primary); border-color: var(--brand-primary); }
#filePicker .fp-toolbar button.secondary { color: var(--foreground); background: var(--background); }
#filePicker .fp-toolbar button.quiet { color: var(--muted); }
#filePicker .fp-table-wrap { max-height: 50vh; overflow: auto; border: 1px solid var(--brand-gray); border-radius: 6px; }
#filePicker .fp-table { font: 13px system-ui, sans-serif; }
#filePicker .fp-table th, #filePicker .fp-table td { padding: 6px 10px; border-color: var(--brand-gray); }
#filePicker .fp-table th { color: var(--muted); letter-spacing: 0; }
#filePicker .tree-name { min-width: 0; padding-top: 0; padding-bottom: 0; color: var(--foreground); font-family: inherit; }
#filePicker .tree-file .tree-name { overflow: hidden; text-overflow: ellipsis; }
#filePicker .fp-actions { white-space: nowrap; }
#filePicker .fp-action { margin-right: 4px; padding: 3px 6px; border: 0; border-radius: 4px; color: var(--brand-primary); background: transparent; cursor: pointer; font: 11px system-ui, sans-serif; }
#filePicker .fp-status-uploading { color: var(--brand-primary); }
#filePicker .fp-status-parsing { color: var(--brand-primary); }
#filePicker .fp-status-parsed { color: var(--green); }
#filePicker .fp-status-duplicate { color: var(--muted); }
#filePicker .fp-status-error { color: var(--destructive); }
#filePicker .fp-status, #filePicker .fp-count { color: var(--muted); font: 12px system-ui, sans-serif; }
</style> </style>
</head> </head>
<body> <body>
@@ -90,22 +110,9 @@
Загрузка договоров/приложений/спецификаций Загрузка договоров/приложений/спецификаций
</div> </div>
<div class="card-body"> <div class="card-body">
<input type="file" id="fileInput" accept=".doc,.docx,.pdf,.zip" multiple style="margin-bottom:6px;width:100%;">
<input type="file" id="folderInput" webkitdirectory multiple style="display:none;">
<div style="margin-bottom:6px;">
<button type="button" class="btn" id="folderBtn" style="font-size:12px;height:30px;">📁 Выбрать папку</button>
</div>
<div style="text-align:right;font-size:11px;color:var(--muted);margin-bottom:6px;">Порядок определяется автоматически при классификации</div> <div style="text-align:right;font-size:11px;color:var(--muted);margin-bottom:6px;">Порядок определяется автоматически при классификации</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:6px;">⚠ При совпадении имён — запрос на перезапись (OK / Отмена). Файлы из ZIP-архивов загружаются через тот же поток.</div> <div style="font-size:11px;color:var(--muted);margin-bottom:6px;">Выберите PDF, DOC или DOCX. ZIP раскрываются рекурсивно; правила из корневого <code>obscure.lst</code> применяются при выборе папки.</div>
<div id="filePicker"></div>
<div class="table-wrap">
<table>
<thead>
<tr><th>Имя</th><th style="width:130px;">Изменён</th><th style="width:90px;">Размер</th><th style="width:115px;">Парсинг</th><th style="width:30px;">Текст</th><th style="width:30px;">✕</th></tr>
</thead>
<tbody id="fileTable"></tbody>
</table>
</div>
<button class="btn btn-primary" id="llmBtn" style="width:100%;justify-content:center;margin-top:8px;display:none;background:var(--green);border-color:var(--green);"> <button class="btn btn-primary" id="llmBtn" style="width:100%;justify-content:center;margin-top:8px;display:none;background:var(--green);border-color:var(--green);">
<i data-lucide="scale" style="width:16px;height:16px;"></i> Общее сравнение <i data-lucide="scale" style="width:16px;height:16px;"></i> Общее сравнение
@@ -220,10 +227,7 @@
</div> </div>
</div> </div>
<script type="module"> <script src="/file-picker/file-picker.iife.js"></script>
import { listZipFiles } from '/upload/zip/list_zip_files.js';
window.listZipFiles = listZipFiles;
</script>
<script src="/static/state.js?v=2.0.21"></script> <script src="/static/state.js?v=2.0.21"></script>
<script src="/static/app_utils.js?v=2.0.21"></script> <script src="/static/app_utils.js?v=2.0.21"></script>
<script src="/static/files.js?v=2.0.21"></script> <script src="/static/files.js?v=2.0.21"></script>
+48
View File
@@ -46,3 +46,51 @@ class TestGroups:
def test_missing_batch(self, client): def test_missing_batch(self, client):
r = client.get("/api/groups") r = client.get("/api/groups")
assert r.status_code == 400 assert r.status_code == 400
class TestSync:
def test_requires_batch_id(self, client):
response = client.post("/api/sync", json={"keep_ids": []})
assert response.status_code == 400
assert response.get_json()["error"] == "batch_id required"
def test_only_deletes_documents_in_requested_batch(self, client):
from db import documents
from db.connection import execute, query
doc_a = documents.insert("a.pdf", "application/pdf", "", batch_id="batch-a")
doc_b = documents.insert("b.pdf", "application/pdf", "", batch_id="batch-b")
execute(
"INSERT INTO prompts (id, role, name, body) VALUES (%s, %s, %s, %s)",
("prompt-1", "classify", "custom", "keep me"),
)
response = client.post(
"/api/sync",
json={"batch_id": "batch-a", "keep_ids": []},
)
assert response.status_code == 200
assert response.get_json()["deleted"] == 1
assert documents.get(doc_a["id"]) is None
assert documents.get(doc_b["id"]) is not None
assert query("SELECT body FROM prompts WHERE id = %s", ("prompt-1",)) == [
{"body": "keep me"}
]
def test_cleanup_endpoint_is_not_exposed(self, client):
response = client.post("/api/cleanup")
assert response.status_code == 404
def test_file_picker_bundle_is_served(self, client):
response = client.get("/file-picker/file-picker.iife.js")
assert response.status_code == 200
assert b"initFilePicker" in response.data
def test_application_startup_preserves_database(self, client):
from app import create_app
from db import documents
doc = documents.insert("keep.pdf", "application/pdf", "", batch_id="batch-a")
create_app()
assert documents.get(doc["id"]) is not None
+69
View File
@@ -0,0 +1,69 @@
import os
import sys
from flask import Flask
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
def test_sink_receives_contract_metadata_without_creating_session(monkeypatch):
from upload.backend.upload_refs import blueprint
calls = []
class FakeHttpClient:
def __init__(self, **_kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def delete(self, _url):
return None
def sink(name, content, **metadata):
calls.append((name, content, metadata))
return {"ok": True, "doc_id": "doc-1"}
def unexpected_create_session():
raise AssertionError("sink mode must not create a session")
monkeypatch.setattr(blueprint.httpx, "Client", FakeHttpClient)
monkeypatch.setattr(blueprint, "pull_file", lambda *_args, **_kwargs: b"document")
monkeypatch.setattr(blueprint, "create_session", unexpected_create_session)
app = Flask(__name__)
app.register_blueprint(blueprint.create_upload_refs_blueprint(
{"apiPrefix": "/api", "vmUploadPrefix": "https://vm.example/uploads/"},
sink=sink,
))
response = app.test_client().post("/api/upload_refs", json={
"batch_id": "batch-1",
"contract_id": "contract-1",
"zip_source": "folder/archive.zip",
"files": [{
"name": "agreement.pdf",
"size": 8,
"url": "https://vm.example/uploads/token",
}],
})
assert response.status_code == 200
assert response.get_json()["results"] == [
{"name": "agreement.pdf", "ok": True, "doc_id": "doc-1"}
]
assert calls == [(
"agreement.pdf",
b"document",
{
"batch_id": "batch-1",
"contract_id": "contract-1",
"zip_source": "folder/archive.zip",
},
)]
+56 -83
View File
@@ -30,49 +30,49 @@ upload/
## Подключение фронта ## Подключение фронта
Подключить ES-модули (`<script type="module">`) и собрать слой 1: Исходники — ES-модули. Для обычной страницы соберите IIFE-бандл:
```js ```sh
import { initUploadTable } from './upload/frontend/table/init_upload_table.js'; cd upload/frontend
import { uploadViaVM } from './upload/frontend/upload/upload_via_vm.js'; npm install
npm run build
const cfg = {
allowedExt: ['.pdf', '.doc', '.docx', '.txt', '.md'],
maxFileBytes: 50 * 1024 * 1024,
maxSessionBytes: 500 * 1024 * 1024,
estMbSec: 12,
};
const table = initUploadTable(cfg, {
fileInput: document.getElementById('fileInput'), // <input type="file" multiple>
folderInput: document.getElementById('folderInput'), // <input webkitdirectory>
tableBody: document.getElementById('fileList'), // <tbody>
countEl: document.getElementById('fileCount'),
uploadBtnEl: document.getElementById('uploadBtn'),
onStatus(cls, text) { /* сообщения (cls: ''|'progress'|'done'|'error') */ },
});
// Слой 2 — закачка учитываемых файлов на ВМ:
// idxInSf[k] — индекс k-го отправляемого файла в строках таблицы (своя логика маппинга)
const res = await uploadViaVM(toSend, cfg.vmUploadUrl, {
session: currentSid,
onStatus(k, html) { table.setStatus(idxInSf[k], html); }, // прогресс → ячейка таблицы
onUploadStatus(text) { /* статусная строка */ },
onXHR(xhr) { activeXHR = xhr; }, // регистрация активного PUT для отмены (abort)
});
// res = {ok:true, session, count} | {ok:false, error}
// После этого у вас в сессии res.session лежат файлы — запускайте СВОЮ обработку.
``` ```
API слоя 1 (`initUploadTable(cfg, els)` → `table`): Бандл появится в `site/static/dist/file-picker.iife.js`; Contracts отдаёт его по
- `addFiles(File[])` — дедуп + раскрытие ZIP + фильтр + лимиты; `/file-picker/file-picker.iife.js`. Инициализация picker:
- `getFiles()` → `[{name, size, file}]` — **только учитываемые** (без сверхлимитных);
- `getOverNames()` → `Set` — имена сверх лимита; ```js
- `setStatus(idx, html)` — статус в ячейке таблицы; const picker = window.FilePicker.initFilePicker({
- `render()` — перерисовать таблицу; mount: '#filePicker',
- `clear()` — очистить список; allowedExt: ['.pdf', '.doc', '.docx'],
- `setBusy(bool)` — заблокировать список на время загрузки/обработки; onChange(files) { /* синхронизировать выбранные leaf-файлы с приложением */ },
- `state` — доступ к состоянию (для слоя 3: установить `state.proc` для 3-секционной таблицы). });
```
Picker строит дерево файлов/папок/ZIP, раскрывает вложенные ZIP, фильтрует
неподдерживаемые расширения и удаляет дубликаты по `path + size`. В выбранной
папке корневой `obscure.lst` применяется как manifest включения/исключения.
`getFiles()` возвращает leaf-записи `{id, path, name, size, file}`. Методы
`setStatus(id, {kind, text})`, `setDocument(id, docId)`, `setMessage(text)`,
`remove(id)`, `clear()`, `render()` и `destroy()` обслуживают UI.
Передача файлов выполняется по одному файлу за вызов/итерацию:
```js
const result = await window.FilePicker.uploadViaVM(files, {
vmUploadUrl: 'https://example.invalid/contracts-upload/',
backendUploadUrl: '/api/upload_refs',
getExtraPayload(fileEntry) {
return {batch_id, contract_id, zip_source: fileEntry.zip_source};
},
onFileComplete({result}) { /* результат sink: doc_id, parsed или ошибка */ },
});
```
`uploadViaVM` принимает `AbortSignal`, сообщает прогресс и передаёт поля
`getExtraPayload` в JSON POST к `upload_refs`. При настроенном `sink` результат
каждого файла передаётся в `onFileComplete` как `result`. Обработку и сохранение
результатов приложение определяет самостоятельно.
--- ---
@@ -95,45 +95,30 @@ app.register_blueprint(create_upload_refs_blueprint({
})) }))
``` ```
Эндпоинт: `POST {apiPrefix}/upload_refs` — принимает JSON Эндпоинт: `POST {apiPrefix}/upload_refs` — принимает JSON с `session`, `files`
`{"session": "...", "files": [{"name", "size", "url"}]}`, тянет каждый файл с ВМ и сквозными полями `batch_id`, `contract_id`, `zip_source`; тянет каждый файл с
(SSRF-валидация по `vmUploadPrefix`, `_safe_name`, ретраи), кладёт в сессию. ВМ (SSRF-валидация по `vmUploadPrefix`, безопасное имя, ретраи). Без `sink`
Возвращает `{"ok": true, "session", "count"}`. файлы кладутся в in-memory сессию и ответ содержит `session`/`count`. С `sink`
ответ содержит `results` с результатом каждого файла и обработку/хранение
определяет приложение.
Сессия: `create_session()` → sid; `add_file(sid, name, content)` (лимит 500МБ); Сессия: `create_session()` → sid; `add_file(sid, name, content)` (лимит 500МБ);
`get_files(sid)` → `[(name, bytes), ...]` или `None`. TTL 30 мин (таймер в фоне). `get_files(sid)` → `[(name, bytes), ...]` или `None`. TTL 30 мин (таймер в фоне).
--- ---
## Раздача модуля в Flask (обязательно) ## Раздача фронтенда
Фронт-модули — ES-модули, браузер грузит их по HTTP (не через file://). В `contracts-flask` `pages_bp.py` отдаёт исходные модули по `/upload/<path>`
Добавьте route, который отдаёт папку `upload/frontend` (как в drhider `site/routes/main_bp.py`): и собранный IIFE по `/file-picker/<path>`. Для другого приложения нужно
раздавать его собранный `file-picker.iife.js` по HTTP и подключать `<script src>`.
```python
# в любом blueprint приложения
import os
from flask import send_from_directory
_UPLOAD_FRONTEND = os.path.join(os.path.dirname(os.path.dirname(__file__)),
"upload", "frontend")
@bp.route("/upload/<path:filename>")
def upload_frontend(filename):
return send_from_directory(_UPLOAD_FRONTEND, filename)
```
Тогда импорты в браузере: `import { initUploadTable } from '/upload/table/init_upload_table.js';`.
> Если фронт-модули хостятся отдельно (другой домен/приложение) — путь `/upload/...`
> и CORS настраиваются под ваш случай (правка приложения/nginx, не кода модуля).
--- ---
## Слой 3: как подключить обработку (опционально) ## Слой 3: обработка приложения
Модуль отдаёт файлы в сессию, а обрабатываете их ВЫ (обфускация, конвертация, ...). Без `sink` модуль отдаёт файлы в in-memory сессию, а обрабатываете их ВЫ
После `uploadViaVM` файлы лежат в `res.session`: (обфускация, конвертация и т. п.). После `uploadViaVM` файлы лежат в `res.session`:
```python ```python
files = get_files(res.session) # [(name, bytes), ...] files = get_files(res.session) # [(name, bytes), ...]
@@ -141,21 +126,9 @@ files = get_files(res.session) # [(name, bytes), ...]
store_result(res.session, result_zip) # если нужно отдать результат обратно store_result(res.session, result_zip) # если нужно отдать результат обратно
``` ```
Для 3-секционной таблицы прогресса (готово/текущий/ожидают) модуль предоставляет С `sink` файлы не попадают в in-memory сессию: обработчик получает их как
`renderProcTable` — достаточно дать слою 3 доступ к `table.state.proc`: `sink(name, content, batch_id=..., contract_id=..., zip_source=...)`, а результат
каждой обработки возвращается в `onFileComplete`.
```js
table.state.proc = { phase: 'processing', procState: {}, procExtractRate: null };
function syncProcCtx() {
table.state.proc.phase = phase;
table.state.proc.procState = procState; // {idx: {st, elapsed, eta, chars, t0}}
table.state.proc.procExtractRate = rate; // сек/МБ (для оценок ожидающих)
}
syncProcCtx();
table.render(); // сам выберет renderProcTable при phase==='processing'
```
Статусы в ячейках: `table.setStatus(idx, html)`.
--- ---
+1 -1
View File
@@ -67,7 +67,7 @@ def create_upload_refs_blueprint(cfg: dict, sink=None) -> Blueprint:
удаляется с ВМ (best-effort; TTL-чистка на ВМ тоже есть). удаляется с ВМ (best-effort; TTL-чистка на ВМ тоже есть).
""" """
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
sid = data.get("session") or create_session() sid = data.get("session") or (create_session() if sink is None else "")
refs = data.get("files") or [] refs = data.get("files") or []
if not refs: if not refs:
log.warning("upload_refs: no files, sid=%s", sid) log.warning("upload_refs: no files, sid=%s", sid)
+1
View File
@@ -0,0 +1 @@
node_modules/
+245
View File
@@ -0,0 +1,245 @@
import { addFiles, onFilesChange } from './table/on_files_change.js';
import { onFolderChange } from './table/on_folder_change.js';
import { findNode, flattenFiles, render } from './table/render.js';
import { esc } from './table/esc.js';
const DEFAULTS = {
allowedExt: [],
labels: {
pickFiles: 'Выбрать файлы',
pickFolder: 'Выбрать папку',
clear: 'Очистить',
empty: 'Нет выбранных файлов',
statusReady: 'готов',
remove: 'Удалить',
details: 'Классификация',
text: 'Текст документа',
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
count: (count, bytes) => `${count} файлов · ${bytes} B`,
},
layout: { columns: ['path', 'size', 'status'], controls: ['files', 'folder', 'clear'] },
limits: {
maxEntries: 1000,
maxTotalBytes: 100 * 1024 * 1024,
maxEntryBytes: 50 * 1024 * 1024,
maxDepth: 20,
},
};
function resolveMount(mount) {
const element = typeof mount === 'string' ? document.querySelector(mount) : mount;
if (!element) throw new Error('File picker mount element не найден');
return element;
}
function normalizeConfig(config) {
return {
...config,
allowedExt: config.allowedExt || DEFAULTS.allowedExt,
labels: {
...DEFAULTS.labels,
...(config.labels || {}),
columns: { ...DEFAULTS.labels.columns, ...(config.labels?.columns || {}) },
},
layout: { ...DEFAULTS.layout, ...(config.layout || {}) },
limits: { ...DEFAULTS.limits, ...(config.limits || {}) },
};
}
function injectStyles() {
if (document.querySelector('style[data-file-picker]')) return;
const style = document.createElement('style');
style.dataset.filePicker = '';
style.textContent = `
.file-picker { color: #17211b; font-family: Georgia, 'Times New Roman', serif; }
.file-picker .fp-toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }
.file-picker .fp-toolbar button { border: 0; border-radius: 4px; padding: 12px 17px; color: #f7faf4; background: #1f5a3b; font: 700 13px sans-serif; cursor: pointer; }
.file-picker .fp-toolbar button.secondary { color: #1f5a3b; background: #cbdcc9; }
.file-picker .fp-toolbar button.quiet { color: #5d6a61; background: transparent; }
.file-picker .fp-table-wrap { overflow-x: auto; border-top: 1px solid #b9c7ba; }
.file-picker .fp-table { width: 100%; border-collapse: collapse; font: 14px/1.4 sans-serif; }
.file-picker .fp-table th, .file-picker .fp-table td { padding: 14px 10px; border-bottom: 1px solid #cbd5cc; text-align: left; }
.file-picker .fp-table th { color: #56745f; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
.file-picker .fp-status, .file-picker .fp-count { color: #5d6a61; font: 13px sans-serif; }
.file-picker .fp-count { min-height: 22px; }
.file-picker .tree-name { display: inline-flex; align-items: center; gap: 8px; min-width: 240px; padding-top: 0; padding-bottom: 0; border: 0; background: transparent; color: #17211b; font: inherit; text-align: left; }
.file-picker .tree-group { padding-left: 0; cursor: pointer; }
.file-picker .tree-chevron { display: inline-block; width: 12px; color: #78917d; font-size: 10px; }
.file-picker .remove-btn { padding: 2px 7px; color: #8c5148; background: transparent; font: 20px/1 sans-serif; }
.file-picker .tree-row td:first-child { white-space: nowrap; }
@media (max-width: 600px) { .file-picker .fp-toolbar button { flex: 1 1 42%; } }
`;
document.head.append(style);
}
function buildMarkup(root, cfg) {
const controls = cfg.layout.controls || [];
const control = (name, id, label, className = '') => controls.includes(name)
? `<button id="${id}" type="button" class="${className}">${esc(label)}</button>` : '';
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns);
const headings = columns.map((column) => `<th>${esc(cfg.labels.columns[column] || column)}</th>`).join('');
// Обычный file input не принимает manifest: `.lst` ему не нужен.
const fileAccept = esc([...new Set([...cfg.allowedExt, '.zip'])].join(','));
// Folder input дополнительно объявляет `.lst`: при webkitdirectory Chromium
// обычно игнорирует accept, но явное расширение служит страховкой, чтобы
// `obscure.lst` попал в FileList. Сам файл в дерево не добавляется.
const folderAccept = esc([...new Set([...cfg.allowedExt, '.zip', '.lst'])].join(','));
root.innerHTML = `
<div class="file-picker${cfg.layout.theme ? ` ${esc(cfg.layout.theme)}` : ''}">
<div class="fp-toolbar" aria-label="${esc(cfg.labels.pickFiles)}">
<input class="fp-file-input" type="file" accept="${fileAccept}" multiple hidden>
<input class="fp-folder-input" type="file" accept="${folderAccept}" webkitdirectory directory multiple hidden>
${control('files', 'fp-files-btn', cfg.labels.pickFiles)}
${control('folder', 'fp-folder-btn', cfg.labels.pickFolder, 'secondary')}
${control('clear', 'fp-clear-btn', cfg.labels.clear, 'quiet')}
</div>
<p class="fp-status" role="status"></p>
<div class="fp-table-wrap"><table class="fp-table"><thead><tr>${headings}<th></th></tr></thead><tbody class="fp-table-body"></tbody></table></div>
<p class="fp-count"></p>
</div>`;
}
function notify(cfg, state) {
if (typeof cfg.onChange === 'function') cfg.onChange([...flattenFiles(state.nodes)]);
}
function removeMetadata(state, node) {
if (node.kind === 'file') {
state.fileKeys.delete(`${node.path}\u0000${node.file.size}`);
state.statuses.delete(node.id);
state.documents.delete(node.id);
return;
}
node.children.forEach((child) => removeMetadata(state, child));
}
function removeNode(state, id) {
const found = findNode(state.nodes, id);
if (!found) return false;
removeMetadata(state, found.node);
found.nodes.splice(found.nodes.indexOf(found.node), 1);
return true;
}
export function initFilePicker(config) {
const cfg = normalizeConfig(config);
const mount = resolveMount(cfg.mount);
injectStyles();
buildMarkup(mount, cfg);
const root = mount.firstElementChild;
const elements = {
fileInputEl: root.querySelector('.fp-file-input'),
folderInputEl: root.querySelector('.fp-folder-input'),
statusEl: root.querySelector('.fp-status'),
tableBodyEl: root.querySelector('.fp-table-body'),
countEl: root.querySelector('.fp-count'),
};
const state = {
nodes: [],
fileKeys: new Set(),
busy: false,
statuses: new Map(),
documents: new Map(),
};
const listeners = [];
const listen = (target, event, handler) => {
target.addEventListener(event, handler);
listeners.push(() => target.removeEventListener(event, handler));
};
const redraw = () => render(state, elements, cfg);
const filesChange = onFilesChange(state, cfg, elements);
const folderChange = onFolderChange(state, cfg, elements);
listen(elements.fileInputEl, 'change', async () => { await filesChange(); notify(cfg, state); });
listen(elements.folderInputEl, 'change', async () => { await folderChange(); notify(cfg, state); });
listen(elements.tableBodyEl, 'click', (event) => {
const toggle = event.target.closest('[data-toggle]');
if (toggle) {
const found = findNode(state.nodes, toggle.dataset.toggle);
if (found) found.node.expanded = !found.node.expanded;
redraw();
return;
}
const details = event.target.closest('[data-details]');
if (details) {
if (typeof cfg.onDetails === 'function') cfg.onDetails(details.dataset.details);
return;
}
const text = event.target.closest('[data-text]');
if (text) {
if (typeof cfg.onText === 'function') cfg.onText(text.dataset.text);
return;
}
const remove = event.target.closest('[data-remove]');
if (remove) {
if (removeNode(state, remove.dataset.remove)) {
redraw();
notify(cfg, state);
}
}
});
if (root.querySelector('#fp-files-btn')) listen(root.querySelector('#fp-files-btn'), 'click', () => elements.fileInputEl.click());
if (root.querySelector('#fp-folder-btn')) listen(root.querySelector('#fp-folder-btn'), 'click', () => elements.folderInputEl.click());
if (root.querySelector('#fp-clear-btn')) listen(root.querySelector('#fp-clear-btn'), 'click', () => {
state.nodes = [];
state.fileKeys.clear();
state.statuses.clear();
state.documents.clear();
if (elements.statusEl) elements.statusEl.textContent = '';
redraw();
notify(cfg, state);
});
redraw();
return {
pickFiles: () => elements.fileInputEl.click(),
pickFolder: () => elements.folderInputEl.click(),
addFiles: async (files) => { await addFiles(state, cfg, files, elements); notify(cfg, state); },
getFiles: () => [...flattenFiles(state.nodes)],
setStatus: (id, status) => {
if (!findNode(state.nodes, id)) return false;
state.statuses.set(id, {
kind: String(status?.kind || 'ready'),
text: String(status?.text || ''),
});
redraw();
return true;
},
setDocument: (id, documentId) => {
if (!findNode(state.nodes, id)) return false;
if (documentId) state.documents.set(id, String(documentId));
else state.documents.delete(id);
redraw();
return true;
},
setMessage: (message) => {
if (elements.statusEl) elements.statusEl.textContent = String(message || '');
},
remove: (id) => {
if (removeNode(state, id)) {
redraw();
notify(cfg, state);
}
},
clear: () => {
state.nodes = [];
state.fileKeys.clear();
state.statuses.clear();
state.documents.clear();
if (elements.statusEl) elements.statusEl.textContent = '';
redraw();
notify(cfg, state);
},
render: redraw,
destroy: () => {
listeners.forEach((removeListener) => removeListener());
state.nodes = [];
state.fileKeys.clear();
state.statuses.clear();
state.documents.clear();
mount.replaceChildren();
},
};
}
export { putToVm } from './upload/put_to_vm.js';
export { uploadViaVM } from './upload/upload_via_vm.js';
export { DEFAULTS };
+10 -1
View File
@@ -2,5 +2,14 @@
"name": "upload-frontend", "name": "upload-frontend",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Переиспользуемый фронт слоёв 1 и 2 (выбор файлов + закачка через ВМ). Модули ES — подключаются в браузере через <script type=\"module\">; Node-режим нужен для юнит-тестов zip." "description": "Picker tree and VM upload frontend shared with Obfuscator.",
"scripts": {
"build": "mkdir -p ../../site/static/dist && esbuild index.js --bundle --format=iife --global-name=FilePicker --outfile=../../site/static/dist/file-picker.iife.js && cp node_modules/fflate/LICENSE ../../site/static/dist/fflate-LICENSE.txt"
},
"dependencies": {
"fflate": "0.8.3"
},
"devDependencies": {
"esbuild": "0.25.12"
}
} }
+50 -43
View File
@@ -1,47 +1,54 @@
// addFileWithDedup — дедуп «имя+размер», суффиксы _2/_3, лимиты (over). /**
// Мутирует state. Возвращает true если файл добавлен (или переведён в over), * Добавляет узел файла или группы в состояние picker с рекурсивной дедупликацией.
// false если это дедуп (обновлена только дата). *
* @param {{nodes: Array, fileKeys: Set<string>}} state Внутреннее состояние таблицы.
export function addFileWithDedup(state, file, cfg) { * @param {{kind: string, path: string, file?: File, children?: Array}} fileNode
const size = file.size; * Корневой узел файла, папки или ZIP-архива.
const mtime = file.lastModified; * @returns {boolean} True, если в состояние добавлен хотя бы один узел.
let name = file.name; *
const maxFileBytes = cfg.maxFileBytes; * Ключ дедупликации состоит из полного пути и размера файла. Это позволяет
const maxSessionBytes = cfg.maxSessionBytes; * повторно выбрать файл после удаления и одновременно не скрывает файл с тем
* же путём, но другим размером.
if (state.fileMeta.has(name)) { */
const e = state.fileMeta.get(name); export function addFileWithDedup(state, fileNode) {
if (e.size === size) { // Удаляет дубли и пустые группы снизу вверх, сохраняя исходные File objects.
// Тот же файл (имя+размер) — дедуп. Дату не сравниваем: файл могли пересохранить const accept = (node) => {
// с тем же содержимым. Оставляем более свежий по дате. if (node.kind === 'file') {
if (mtime > e.mtime) { // NUL-разделитель исключает неоднозначность при склейке пути и размера.
e.mtime = mtime; const key = `${node.path}\u0000${node.file.size}`;
const idx = state.files.findIndex(f => f.name === name); if (state.fileKeys.has(key)) return null;
if (idx >= 0) state.files[idx] = new File([file], name, { lastModified: mtime }); state.fileKeys.add(key);
} return node;
return false; // дедуп: файл не добавлен (обновлена только дата) }
// Группа нужна только пока после фильтрации в ней остался хотя бы один 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 }); state.nodes.push(accepted);
// Определяем, превышает ли файл лимит (по размеру файла или суммарный) — не участвует в обфускации
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 }));
return true; 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);
});
}
+17 -4
View File
@@ -1,5 +1,18 @@
// esc — экранирование HTML (защита от self-XSS именами файлов). /**
* Экранирует текст перед вставкой в HTML-шаблон, собираемый через innerHTML.
export function esc(s) { *
return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); * @param {*} value Имя файла, путь или сообщение статуса.
* @returns {string} Строка с заменёнными HTML-значимыми символами.
*
* Экранируются и кавычки, потому что значение может попасть не только в текст
* кнопки, но и в HTML-атрибут вроде data-path или aria-label.
*/
export function esc(value) {
return String(value).replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[character]));
} }
-8
View File
@@ -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;
}
-6
View File
@@ -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 + 'с';
}
+10 -6
View File
@@ -1,7 +1,11 @@
// fs — формат размера: B / KB / MB. /**
* Преобразует размер файла из байтов в короткую строку для таблицы.
export function fs(b) { *
return b < 1024 ? b + ' B' * @param {number} bytes Размер в байтах.
: b < 1048576 ? (b / 1024).toFixed(1) + ' KB' * @returns {string} Значение в B, KB или MB с одним знаком после запятой.
: (b / 1048576).toFixed(1) + ' 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); },
};
}
+394
View File
@@ -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 };
}
+63 -44
View File
@@ -1,56 +1,75 @@
// Обработчик <input type="file" multiple> change: дедуп + раскрытие ZIP + фильтр.
// Экспортируется и чистая логика добавления массива файлов (addFiles),
// и фабрика обработчика для input (makeFilesChangeHandler).
import { listZipFiles } from '../zip/list_zip_files.js'; import { listZipFiles } from '../zip/list_zip_files.js';
import { addFileWithDedup } from './add_file_with_dedup.js'; import { addFileWithDedup } from './add_file_with_dedup.js';
import { render } from './render.js'; import { render } from './render.js';
// Добавить массив файлов в список (дедуп + раскрытие ZIP + лимиты). /**
// files: File[]. els.fileInput — для синхронизации input.files (DataTransfer), * Обрабатывает список файлов из обычного file input.
// чтобы повторный выбор того же файла сработал. onStatus(cls, text) — колбэк сообщений. *
export async function addFiles(state, cfg, files, els, onStatus) { * @param {{fileKeys: Set<string>, nodes: Array, busy: boolean}} state Состояние picker.
const incoming = Array.from(files); * @param {{allowedExt: string[]}} cfg Конфигурация разрешённых расширений.
const hasZip = incoming.some(f => f.name.toLowerCase().endsWith('.zip')); * @param {FileList|File[]} files Выбранные browser File objects.
if (hasZip) { * @param {{tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-элементы вывода.
document.body.style.cursor = 'wait'; * @returns {Promise<void>} Завершается после разбора всех ZIP и перерисовки таблицы.
if (onStatus) onStatus('progress', 'Разбираю архивы…'); *
} * Обычные документы добавляются сразу. ZIP читаются асинхронно в браузере;
try { * ошибка одного архива не отменяет обработку остальных выбранных файлов.
for (const f of incoming) { */
if (f.name.toLowerCase().endsWith('.zip')) { export async function addFiles(state, cfg, files, elements) {
try { for (const file of Array.from(files)) {
const nested = await listZipFiles(f, cfg.allowedExt); if (!file.name.toLowerCase().endsWith('.zip')) {
if (nested.length) nested.forEach(x => addFileWithDedup(state, x, cfg)); // Фильтр повторяет accept в HTML, потому что accept не является защитой API.
else addFileWithDedup(state, f, cfg); // в архиве нет документов — архив как есть if (!cfg.allowedExt.some((extension) => file.name.toLowerCase().endsWith(extension.toLowerCase()))) {
} catch (err) { continue;
addFileWithDedup(state, f, cfg); // не удалось распаковать — zip как есть
}
} else {
addFileWithDedup(state, f, cfg);
} }
// Обычный файл получает имя как путь: у file input нет относительного пути.
addFileWithDedup(state, {
id: crypto.randomUUID(), kind: 'file', name: file.name, path: file.name,
file, children: [], expanded: true,
});
continue;
} }
} finally { try {
if (hasZip) { // listZipFiles возвращает дерево только с разрешёнными leaf-файлами.
document.body.style.cursor = ''; const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
if (onStatus) onStatus('', ''); 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 — чтобы повторный выбор того же файла сработал // Единая перерисовка после всей пачки предотвращает промежуточные состояния UI.
if (els && els.fileInput && els.fileInput.files) { render(state, elements, cfg);
const d = new DataTransfer();
state.files.forEach(f => d.items.add(f));
els.fileInput.files = d.files;
}
render(state, els, cfg);
} }
// Фабрика обработчика change для <input type="file">. /**
// ctx = { state, cfg, els, onStatus } * Создаёт обработчик change для обычного выбора файлов.
export function makeFilesChangeHandler(ctx) { *
const { state, cfg, els, onStatus } = ctx; * @param {object} state Состояние picker, включая флаг busy.
return () => { * @param {object} cfg Конфигурация picker.
if (state.busy) return; // во время загрузки/обработки менять список нельзя * @param {object} elements DOM-элементы picker.
addFiles(state, cfg, els.fileInput.files, els, onStatus); * @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;
}
}; };
} }
+95 -46
View File
@@ -1,58 +1,107 @@
// Обработчик <input webkitdirectory> change: выбор целой папки, рекурсивно,
// относительный путь сохраняется (верхняя папка отбрасывается).
import { listZipFiles } from '../zip/list_zip_files.js'; import { listZipFiles } from '../zip/list_zip_files.js';
import { addFileWithDedup } from './add_file_with_dedup.js'; import { addFileWithDedup } from './add_file_with_dedup.js';
import { rebaseTree } from './rebase_tree.js';
import { render } from './render.js'; import { render } from './render.js';
import { lstFilter } from './lst_filter.js';
// Фабрика обработчика change для input выбора папки. /**
// ctx = { state, cfg, els, onStatus } * Создаёт async-обработчик выбора каталога через webkitdirectory.
export function makeFolderChangeHandler(ctx) { *
const { state, cfg, els, onStatus } = ctx; * @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 () => { return async () => {
if (state.busy) return; // во время загрузки/обработки менять список нельзя if (state.busy) return;
const incoming = Array.from(els.folderInput.files); state.busy = true;
if (!incoming.length) return; // Сброс статуса перед новым выбором: иначе сообщение об ошибке предыдущего
document.body.style.cursor = 'wait'; // выбора (например, «Ошибка чтения архива») останется висеть в интерфейсе.
if (onStatus) onStatus('progress', 'Разбираю папку…'); if (elements.statusEl) elements.statusEl.textContent = '';
let added = 0;
try { try {
for (const f of incoming) { const roots = new Map();
// webkitRelativePath: "TopFolder/Подпапка/file.pdf" — отбрасываем верхнюю папку // Manifest-фильтр: при наличии корневого `obscure.lst` набор файлов
const parts = (f.webkitRelativePath || f.name).split('/'); // ограничивается его правилами, а сам manifest в дерево не попадает.
const rel = parts.slice(1).join('/') || f.name; // Без manifest модуль возвращает исходный список без изменений.
const low = rel.toLowerCase(); const filtered = await lstFilter(elements.folderInputEl.files, [...cfg.allowedExt, '.zip']);
const slashIdx = rel.lastIndexOf('/'); if (filtered.manifest) {
const relDir = slashIdx >= 0 ? rel.slice(0, slashIdx) : ''; // Диагностику показываем в statusEl: ошибки строк и предупреждения правил.
if (low.endsWith('.zip')) { const messages = filtered.diagnostics.map((item) => (
try { item.line ? `строка ${item.line}: ${item.message}` : item.message
const nested = await listZipFiles(f, cfg.allowedExt); ));
if (nested.length) { if (messages.length && elements.statusEl) {
for (const nf of nested) { elements.statusEl.textContent = messages.join('; ');
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++;
} }
// иначе — не документ, пропускаем
} }
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 { } finally {
document.body.style.cursor = ''; state.busy = false;
}
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);
} }
}; };
} }
-13
View File
@@ -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>';
}
+16
View File
@@ -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
View File
@@ -1,29 +1,157 @@
// render — рендер таблицы выбора файлов (обычная).
// Если идёт обработка (state.proc.phase === 'processing') — 3-секционная.
import { esc } from './esc.js'; import { esc } from './esc.js';
import { fs } from './fs.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) { const DEFAULT_RENDER_CONFIG = {
if (state.proc && state.proc.phase === 'processing') { renderProcTable(state, els, cfg); return; } labels: {
if (state.files.length === 0) { statusReady: 'готов',
els.tableBody.innerHTML = '<tr class="empty-row"><td colspan="4">Нет выбранных файлов</td></tr>'; remove: 'Удалить',
} else { empty: 'Нет выбранных файлов',
els.tableBody.innerHTML = state.files.map((f, i) => { columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
const over = state.overNames.has(f.name); count: (count, bytes) => `${count} файлов · ${fs(bytes)}`,
const rowCls = over ? ' class="row-over"' : ''; },
const stTxt = over ? '<span style="color:#c0392b;">🔥 не учитывается</span>' layout: { columns: ['path', 'size', 'status'] },
: '<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(''); function renderConfig(cfg = {}) {
} return {
const overCount = state.files.filter(f => state.overNames.has(f.name)).length; labels: {
const totalSize = state.files.reduce((s, f) => s + (state.overNames.has(f.name) ? 0 : f.size), 0); ...DEFAULT_RENDER_CONFIG.labels,
const cntMain = state.files.length - overCount; ...(cfg.labels || {}),
const totEst = state.files.reduce((s, f) => s + (state.overNames.has(f.name) ? 0 : estForFile(f, cfg.estMbSec)), 0); columns: {
els.countEl.textContent = (overCount ? cntMain + ' учитываются + ' + overCount + ' свыше лимита' : state.files.length) + ' файлов · ' + fs(totalSize) + ' · ~' + fmtSec(totEst); ...DEFAULT_RENDER_CONFIG.labels.columns,
els.uploadBtnEl.disabled = cntMain === 0; ...(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 + ' файлов';
}
-19
View File
@@ -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);
}
-6
View File
@@ -1,6 +0,0 @@
// setStatus — записать HTML-статус в ячейку таблицы (st-<idx>).
export function setStatus(idx, html) {
const e = document.getElementById('st-' + idx);
if (e) e.innerHTML = html;
}
+83 -317
View File
@@ -1,344 +1,110 @@
// Юнит-тесты фронта модуля upload: zip (кириллица, вложенный zip, не-doc) + дедуп/лимиты.
// Запуск: node upload/frontend/test_upload_frontend.mjs
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { deflateRawSync } from 'node:zlib'; import { zipSync } from 'fflate';
import { listZipFiles } from './zip/list_zip_files.js';
import { addFileWithDedup } from './table/add_file_with_dedup.js';
import { flattenFiles, render } from './table/render.js';
import { lstFilter } from './table/lst_filter.js';
import { esc } from './table/esc.js';
// ── Полифилл File (Node 18 не имеет global File) ──
if (typeof globalThis.File === 'undefined') { if (typeof globalThis.File === 'undefined') {
globalThis.File = class File extends Blob { globalThis.File = class File extends Blob {
constructor(parts, name, opts) { constructor(parts, name, options = {}) {
super(parts, opts); super(parts, options);
this.name = name; this.name = name;
this.lastModified = (opts && opts.lastModified) || Date.now(); this.lastModified = options.lastModified || Date.now();
} }
}; };
} }
globalThis.crypto = globalThis.crypto || { randomUUID: () => 'test-picker-id' };
import { listZipFiles } from './zip/list_zip_files.js'; function bytes(text) {
import { parseZip } from './zip/parse_zip.js'; return new TextEncoder().encode(text);
import { decodeZipName } from './zip/decode_zip_name.js';
import { addFileWithDedup } from './table/add_file_with_dedup.js';
import { esc } from './table/esc.js';
import { fs } from './table/fs.js';
import { fmtSec } from './table/fmt_sec.js';
import { estForFile } from './table/est_for_file.js';
const ALLOWED = ['.pdf', '.doc', '.docx', '.txt', '.md'];
// ── CRC32 (для сборки тестовых ZIP) ──
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
t[n] = c >>> 0;
}
return t;
})();
function crc32(bytes) {
let c = 0xffffffff;
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
} }
// ── Сборка минимального ZIP (method 0, UTF-8-флаг) ── function withRelativePath(file, path) {
function makeZip(entries) { Object.defineProperty(file, 'webkitRelativePath', { value: path });
const enc = new TextEncoder(); return file;
const chunks = [];
const central = [];
let offset = 0;
const utf8Flag = 0x0800;
for (const e of entries) {
const nameB = enc.encode(e.name);
const dataB = typeof e.data === 'string' ? enc.encode(e.data) : e.data;
const crc = crc32(dataB);
const lh = new DataView(new ArrayBuffer(30));
lh.setUint32(0, 0x04034b50, true);
lh.setUint16(4, 20, true);
lh.setUint16(6, utf8Flag, true);
lh.setUint16(8, 0, true);
lh.setUint16(10, 0, true);
lh.setUint16(12, 0x21, true);
lh.setUint32(14, crc, true);
lh.setUint32(18, dataB.length, true);
lh.setUint32(22, dataB.length, true);
lh.setUint16(26, nameB.length, true);
lh.setUint16(28, 0, true);
chunks.push(Buffer.from(lh.buffer), Buffer.from(nameB), Buffer.from(dataB));
const cd = new DataView(new ArrayBuffer(46));
cd.setUint32(0, 0x02014b50, true);
cd.setUint16(4, 20, true);
cd.setUint16(6, 20, true);
cd.setUint16(8, utf8Flag, true);
cd.setUint16(10, 0, true);
cd.setUint16(12, 0, true);
cd.setUint16(14, 0x21, true);
cd.setUint32(16, crc, true);
cd.setUint32(20, dataB.length, true);
cd.setUint32(24, dataB.length, true);
cd.setUint16(28, nameB.length, true);
cd.setUint16(30, 0, true);
cd.setUint16(32, 0, true);
cd.setUint16(34, 0, true);
cd.setUint16(36, 0, true);
cd.setUint32(38, 0, true);
cd.setUint32(42, offset, true);
central.push(Buffer.from(cd.buffer), Buffer.from(nameB));
offset += 30 + nameB.length + dataB.length;
}
const cdSize = central.reduce((s, x) => s + x.byteLength, 0);
const cdStart = offset;
const eocd = new DataView(new ArrayBuffer(22));
eocd.setUint32(0, 0x06054b50, true);
eocd.setUint16(4, 0, true);
eocd.setUint16(6, 0, true);
eocd.setUint16(8, entries.length, true);
eocd.setUint16(10, entries.length, true);
eocd.setUint32(12, cdSize, true);
eocd.setUint32(16, cdStart, true);
eocd.setUint16(20, 0, true);
chunks.push(...central, Buffer.from(eocd.buffer));
return Buffer.concat(chunks);
} }
// ── Сборка ZIP с методом 8 (deflate) — для проверки inflateRaw ── async function testRecursiveZipTree() {
async function deflateRaw(bytes) { const nested = zipSync({ 'inside.pdf': bytes('nested') });
// zlib.deflateRawSync — deflate без zlib-заголовка (то, что ждёт inflateRaw) const archive = zipSync({
return deflateRawSync(Buffer.from(bytes)); 'contracts/one.pdf': bytes('one'),
} 'contracts/ignore.txt': bytes('text'),
'nested.zip': nested,
async function makeZipDeflate(entries) { '../escape.pdf': bytes('unsafe'),
const enc = new TextEncoder(); });
const chunks = []; const tree = await listZipFiles(new File([archive], 'outer.zip'), ['.pdf', '.doc', '.docx']);
const central = []; const files = flattenFiles([tree]);
let offset = 0; assert.deepEqual(files.map((file) => file.path).sort(), [
const utf8Flag = 0x0800; 'outer.zip/contracts/one.pdf',
for (const e of entries) { 'outer.zip/nested.zip/inside.pdf',
const nameB = enc.encode(e.name);
const raw = enc.encode(e.data);
const comp = await deflateRaw(raw);
const crc = crc32(raw);
const lh = new DataView(new ArrayBuffer(30));
lh.setUint32(0, 0x04034b50, true);
lh.setUint16(4, 20, true);
lh.setUint16(6, utf8Flag, true);
lh.setUint16(8, 8, true); // method 8 deflate
lh.setUint16(10, 0, true);
lh.setUint16(12, 0x21, true);
lh.setUint32(14, crc, true);
lh.setUint32(18, comp.length, true);
lh.setUint32(22, raw.length, true);
lh.setUint16(26, nameB.length, true);
lh.setUint16(28, 0, true);
chunks.push(Buffer.from(lh.buffer), Buffer.from(nameB), Buffer.from(comp));
const cd = new DataView(new ArrayBuffer(46));
cd.setUint32(0, 0x02014b50, true);
cd.setUint16(4, 20, true);
cd.setUint16(6, 20, true);
cd.setUint16(8, utf8Flag, true);
cd.setUint16(10, 8, true);
cd.setUint16(12, 0, true);
cd.setUint16(14, 0x21, true);
cd.setUint32(16, crc, true);
cd.setUint32(20, comp.length, true);
cd.setUint32(24, raw.length, true);
cd.setUint16(28, nameB.length, true);
cd.setUint16(30, 0, true);
cd.setUint16(32, 0, true);
cd.setUint16(34, 0, true);
cd.setUint16(36, 0, true);
cd.setUint32(38, 0, true);
cd.setUint32(42, offset, true);
central.push(Buffer.from(cd.buffer), Buffer.from(nameB));
offset += 30 + nameB.length + comp.length;
}
const cdSize = central.reduce((s, x) => s + x.byteLength, 0);
const cdStart = offset;
const eocd = new DataView(new ArrayBuffer(22));
eocd.setUint32(0, 0x06054b50, true);
eocd.setUint16(4, 0, true);
eocd.setUint16(6, 0, true);
eocd.setUint16(8, entries.length, true);
eocd.setUint16(10, entries.length, true);
eocd.setUint32(12, cdSize, true);
eocd.setUint32(16, cdStart, true);
eocd.setUint16(20, 0, true);
chunks.push(...central, Buffer.from(eocd.buffer));
return Buffer.concat(chunks);
}
// ── Тесты decodeZipName ──
function testDecodeZipName() {
const utf8 = new TextEncoder().encode('договор.pdf');
assert.equal(decodeZipName(utf8, true), 'договор.pdf');
// Без UTF-8-флага, но байты — валидный UTF-8 с кириллицей → эвристика берёт как есть
assert.equal(decodeZipName(utf8, false), 'договор.pdf');
console.log(' decodeZipName: ok');
}
// ── Тесты listZipFiles ──
async function testZipCyrillic() {
const zip = makeZip([{ name: 'договор.pdf', data: '%PDF-1.4 fake' }]);
const files = await listZipFiles(new File([zip], 'a.zip'), ALLOWED);
assert.equal(files.length, 1);
assert.equal(files[0].name, 'договор.pdf');
console.log(' zip кириллица: ok');
}
async function testZipNested() {
const inner = makeZip([{ name: 'inner.txt', data: 'hi' }]);
const outer = makeZip([
{ name: 'inner.zip', data: inner },
{ name: 'skip.txt', data: 'x' },
]); ]);
const files = await listZipFiles(new File([outer], 'outer.zip'), ALLOWED); assert.equal(files.find((file) => file.name === 'inside.pdf').file.name, 'inside.pdf');
const names = files.map(f => f.name).sort();
assert.deepEqual(names, ['inner.txt', 'skip.txt']);
console.log(' вложенный zip: ok');
} }
async function testZipNonDoc() { function testPathAndSizeDeduplication() {
const zip = makeZip([ const state = { nodes: [], fileKeys: new Set() };
{ name: 'doc.pdf', data: 'pdf' }, const file = new File(['same'], 'a.pdf');
{ name: 'img.png', data: 'png' }, const node = { id: 'one', kind: 'file', name: 'a.pdf', path: 'a.pdf', file, children: [] };
{ name: 'readme.txt', data: 'readme' }, assert.equal(addFileWithDedup(state, node), true);
]); assert.equal(addFileWithDedup(state, {
const files = await listZipFiles(new File([zip], 'a.zip'), ALLOWED); ...node, id: 'two', file: new File(['same'], 'a.pdf'),
const names = files.map(f => f.name).sort(); }), false);
assert.deepEqual(names, ['doc.pdf', 'readme.txt']); assert.equal(addFileWithDedup(state, {
console.log(' не-doc фильтр: ok'); ...node, id: 'three', path: 'folder/a.pdf',
}), true);
assert.deepEqual(flattenFiles(state.nodes).map((entry) => entry.path), ['a.pdf', 'folder/a.pdf']);
} }
async function testZipDeflate() { async function testObscureManifest() {
// deflate-raw требует DecompressionStream('deflate-raw') — в браузере есть, в Node 18 нет const manifest = withRelativePath(new File(['contracts/**\n!**/skip.docx\n'], 'obscure.lst'), 'root/obscure.lst');
let supported = true; const included = withRelativePath(new File(['a'], 'a.pdf'), 'root/contracts/a.pdf');
try { new DecompressionStream('deflate-raw'); } catch (e) { supported = false; } const excluded = withRelativePath(new File(['b'], 'skip.docx'), 'root/contracts/skip.docx');
if (!supported) { const outside = withRelativePath(new File(['c'], 'outside.pdf'), 'root/other/outside.pdf');
console.log(' zip deflate (метод 8): skip — Node 18 не поддерживает deflate-raw'); const result = await lstFilter([manifest, included, excluded, outside], ['.pdf', '.doc', '.docx', '.zip']);
return; assert.equal(result.manifest, manifest);
} assert.deepEqual(result.files, [included]);
const content = 'Hello deflate world '.repeat(50); assert.deepEqual(result.diagnostics, []);
const zip = await makeZipDeflate([{ name: 'deflated.txt', data: content }]);
const files = await listZipFiles(new File([zip], 'a.zip'), ALLOWED);
assert.equal(files.length, 1);
assert.equal(files[0].name, 'deflated.txt');
assert.equal(await files[0].text(), content);
console.log(' zip deflate (метод 8): ok');
} }
async function testParseZipNotZip() { function testEscaping() {
await assert.rejects(() => parseZip(new Uint8Array([1, 2, 3])), /Не ZIP/); assert.equal(esc(`<script title="x">&'`), '&lt;script title=&quot;x&quot;&gt;&amp;&#39;');
console.log(' не-ZIP бросок: ok');
} }
// ── Тесты addFileWithDedup ── function testStatusAndDocumentActionsRenderSafely() {
function newState() { const node = {
return { files: [], fileMeta: new Map(), overNames: new Set(), busy: false, proc: null }; id: 'file-1',
kind: 'file',
name: '<img src=x>',
path: '<img src=x>',
file: new File(['x'], '<img src=x>'),
children: [],
expanded: true,
};
const state = {
nodes: [node],
statuses: new Map([['file-1', { kind: 'error', text: '<script>bad</script>' }]]),
documents: new Map([['file-1', 'doc-1']]),
};
const elements = { tableBodyEl: { innerHTML: '' }, countEl: { textContent: '' } };
render(state, elements);
assert.match(elements.tableBodyEl.innerHTML, /data-details="file-1"/);
assert.match(elements.tableBodyEl.innerHTML, /data-text="file-1"/);
assert.match(elements.tableBodyEl.innerHTML, /&lt;script&gt;bad&lt;\/script&gt;/);
assert.doesNotMatch(elements.tableBodyEl.innerHTML, /<script>bad/);
assert.match(elements.tableBodyEl.innerHTML, /&lt;img src=x&gt;/);
} }
function testDedup() { const tests = [
const st = newState(); ['recursive ZIP tree and unsafe paths', testRecursiveZipTree],
const c = { maxFileBytes: 100, maxSessionBytes: 300 }; ['deduplication by path and size', testPathAndSizeDeduplication],
// одинаковое имя+размер → дедуп (false), список не растёт ['obscure.lst selection filter', testObscureManifest],
assert.equal(addFileWithDedup(st, new File(['aaa'], 'a.txt'), c), true); ['HTML escaping', testEscaping],
assert.equal(st.files.length, 1); ['status and document action rendering', testStatusAndDocumentActionsRenderSafely],
assert.equal(addFileWithDedup(st, new File(['aaa'], 'a.txt'), c), false);
assert.equal(st.files.length, 1);
// имя то же, размер другой → суффикс _2
assert.equal(addFileWithDedup(st, new File(['bbbb'], 'a.txt'), c), true);
assert.equal(st.files.length, 2);
assert.equal(st.files[1].name, 'a_2.txt');
console.log(' дедуп/суффиксы: ok');
}
function testLimits() {
// лимит на один файл
const st = newState();
const c = { maxFileBytes: 100, maxSessionBytes: 300 };
addFileWithDedup(st, new File([new Uint8Array(150)], 'big.bin'), c);
assert.ok(st.overNames.has('big.bin'));
assert.equal(st.files.length, 1);
// суммарный лимит сессии
const st2 = newState();
const c2 = { maxFileBytes: 1000, maxSessionBytes: 200 };
addFileWithDedup(st2, new File([new Uint8Array(150)], 'f1.bin'), c2);
addFileWithDedup(st2, new File([new Uint8Array(60)], 'f2.bin'), c2); // 150+60=210 > 200 → over
assert.ok(st2.overNames.has('f2.bin'));
console.log(' лимиты over: ok');
}
function testSuffixes() {
const st = newState();
const c = { maxFileBytes: 10000, maxSessionBytes: 100000 };
addFileWithDedup(st, new File(['aaa'], 'a.txt'), c);
addFileWithDedup(st, new File(['bbbb'], 'a.txt'), c); // a_2.txt
addFileWithDedup(st, new File(['ccccc'], 'a.txt'), c); // a_3.txt
assert.deepEqual(st.files.map(f => f.name), ['a.txt', 'a_2.txt', 'a_3.txt']);
console.log(' суффиксы _2/_3: ok');
}
function testEsc() {
assert.equal(esc('<a href="x">&\'</a>'), '&lt;a href=&quot;x&quot;&gt;&amp;&#39;&lt;/a&gt;');
console.log(' esc (XSS-экранирование): ok');
}
function testFs() {
assert.equal(fs(0), '0 B');
assert.equal(fs(1023), '1023 B');
assert.equal(fs(1024), '1.0 KB');
assert.equal(fs(1048576), '1.0 MB');
console.log(' fs (размер): ok');
}
function testFmtSec() {
assert.equal(fmtSec(0), '0с');
assert.equal(fmtSec(59), '59с');
assert.equal(fmtSec(60), '1м 0с');
assert.equal(fmtSec(125), '2м 5с');
console.log(' fmtSec: ok');
}
function testEstForFile() {
assert.equal(estForFile({ size: 1048576 }, 12), 12); // 1 МБ × 12 = 12с
assert.equal(estForFile(null, 12), 0);
console.log(' estForFile: ok');
}
// ── Прогон ──
const TESTS = [
['decodeZipName', testDecodeZipName],
['zip кириллица', testZipCyrillic],
['вложенный zip', testZipNested],
['не-doc фильтр', testZipNonDoc],
['zip deflate (метод 8)', testZipDeflate],
['не-ZIP бросок', testParseZipNotZip],
['дедуп/суффиксы', testDedup],
['лимиты over', testLimits],
['суффиксы _2/_3', testSuffixes],
['esc XSS', testEsc],
['fs размер', testFs],
['fmtSec', testFmtSec],
['estForFile', testEstForFile],
]; ];
let failed = 0; for (const [name, test] of tests) {
for (const [name, fn] of TESTS) { await test();
try { console.log(`PASS ${name}`);
await fn();
console.log('PASS ' + name);
} catch (err) {
failed++;
console.error('FAIL ' + name + ': ' + err.message);
}
} }
if (failed) {
console.error(failed + ' тестов упало');
process.exit(1);
}
console.log('Все фронт-тесты прошли');
+136 -169
View File
@@ -1,199 +1,166 @@
// Юнит-тесты слоя 2 (фронт): putToVm + uploadViaVM с моками XMLHttpRequest и fetch.
// Запуск: node upload/frontend/test_upload_layer2.mjs
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
// ── Полифилл File (Node 18 не имеет global File) ──
if (typeof globalThis.File === 'undefined') {
globalThis.File = class File extends Blob {
constructor(parts, name, opts) {
super(parts, opts);
this.name = name;
this.lastModified = (opts && opts.lastModified) || Date.now();
}
};
}
import { putToVm } from './upload/put_to_vm.js'; import { putToVm } from './upload/put_to_vm.js';
import { uploadViaVM } from './upload/upload_via_vm.js'; import { uploadViaVM } from './upload/upload_via_vm.js';
const tick = () => new Promise(r => setTimeout(r, 0)); if (typeof globalThis.File === 'undefined') {
globalThis.File = class File extends Blob {
constructor(parts, name, options = {}) {
super(parts, options);
this.name = name;
this.lastModified = options.lastModified || Date.now();
}
};
}
globalThis.crypto = globalThis.crypto || { randomUUID: () => 'test-upload-id' };
// Node 18 не имеет globalThis.crypto (в браузере есть) — заглушка для uploadViaVM let lastXHR;
globalThis.crypto = globalThis.crypto || { randomUUID: () => 'test-uuid-1234' };
// ── мок XMLHttpRequest ──
let lastXHR = null;
class FakeXHR { class FakeXHR {
constructor() { constructor() {
this.upload = {}; this.upload = {};
this.status = 0; this.status = 0;
this.statusText = '';
this.timeout = 0; this.timeout = 0;
this.onload = null;
this.onerror = null;
this.ontimeout = null;
this.method = null;
this.url = null;
this.body = null;
lastXHR = this; lastXHR = this;
} }
open(method, url) { this.method = method; this.url = url; } open(method, url) {
send(body) { this.body = body; } this.method = method;
this.url = url;
}
send(body) {
this.body = body;
}
complete(status = 200) {
this.status = status;
this.onload();
}
} }
globalThis.XMLHttpRequest = FakeXHR; globalThis.XMLHttpRequest = FakeXHR;
// ── мок fetch ──
let fetchCalls = []; let fetchCalls = [];
function installFetch(respond) { let fetchResponder;
fetchCalls = []; globalThis.fetch = async (url, options) => {
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, options });
fetchCalls.push({ url, opts }); return fetchResponder(fetchCalls.length);
return respond(); };
};
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
async function waitForNextXHR(previous) {
for (let attempt = 0; attempt < 20 && lastXHR === previous; attempt += 1) await tick();
assert.notEqual(lastXHR, previous, 'next PUT request was not started');
return lastXHR;
} }
// ── putToVm ──
async function testPutToVmSuccess() { async function testPutToVmSuccess() {
const f = new File(['abc'], 'a.txt'); const file = new File(['payload'], 'a.pdf');
const p = putToVm(f, 'https://vm/tok_0'); const request = putToVm(file, 'https://vm.example/a');
lastXHR.status = 200;
lastXHR.onload();
await p;
assert.equal(lastXHR.method, 'PUT'); assert.equal(lastXHR.method, 'PUT');
assert.equal(lastXHR.url, 'https://vm/tok_0'); assert.equal(lastXHR.body, file);
assert.equal(lastXHR.body, f); lastXHR.complete();
console.log(' putToVm success: ok'); await request;
} }
async function testPutToVmHttpError() { async function testUploadIncludesPerFilePayloadAndResult() {
const p = putToVm(new File(['abc'], 'a.txt'), 'https://vm/tok_0'); fetchCalls = [];
lastXHR.status = 500; fetchResponder = async (callNumber) => ({
lastXHR.onload(); ok: true,
await assert.rejects(p, /ВМ: HTTP 500/); status: 200,
console.log(' putToVm HTTP error: ok'); statusText: 'OK',
} json: async () => ({
ok: true,
async function testPutToVmNetworkError() { results: [{ ok: true, doc_id: `doc-${callNumber}`, parsed: { status: 'parsed' } }],
const p = putToVm(new File(['abc'], 'a.txt'), 'https://vm/tok_0'); }),
lastXHR.onerror();
await assert.rejects(p, /Сеть/);
console.log(' putToVm network error: ok');
}
async function testPutToVmTimeout() {
const p = putToVm(new File(['abc'], 'a.txt'), 'https://vm/tok_0');
lastXHR.ontimeout();
await assert.rejects(p, /Таймаут/);
console.log(' putToVm timeout: ok');
}
async function testPutToVmOnXHR() {
let registered = null;
const p = putToVm(new File(['abc'], 'a.txt'), 'https://vm/tok_0', { onXHR(x) { registered = x; } });
assert.ok(registered, 'onXHR должен получить XHR для отмены');
registered.status = 200;
registered.onload();
await p;
console.log(' putToVm onXHR регистрация: ok');
}
// ── uploadViaVM ──
async function testUploadViaVMSuccess() {
installFetch(() => ({ ok: true, json: async () => ({ ok: true, session: 'sid123', count: 2 }) }));
const files = [new File(['a'], 'a.txt'), new File(['bb'], 'b.txt')];
const p = uploadViaVM(files, 'https://vm/', { session: '' });
await tick();
lastXHR.status = 200; lastXHR.onload();
await tick();
lastXHR.status = 200; lastXHR.onload();
const res = await p;
assert.equal(res.ok, true);
assert.equal(res.session, 'sid123');
assert.equal(res.count, 2);
assert.equal(fetchCalls.length, 1);
const body = JSON.parse(fetchCalls[0].opts.body);
assert.equal(body.files.length, 2);
assert.equal(body.files[0].name, 'a.txt');
assert.equal(body.files[1].name, 'b.txt');
assert.ok(body.files[0].url.startsWith('https://vm/'));
console.log(' uploadViaVM success: ok');
}
async function testUploadViaVMPutError() {
installFetch(() => ({ ok: true, json: async () => ({}) }));
const files = [new File(['a'], 'a.txt'), new File(['bb'], 'b.txt')];
const p = uploadViaVM(files, 'https://vm/', { session: '' });
await tick();
lastXHR.onerror(); // первый PUT падает
const res = await p;
assert.equal(res.ok, false);
assert.ok(res.error.includes('Ошибка загрузки на ВМ'));
assert.equal(fetchCalls.length, 0); // POST не вызван
console.log(' uploadViaVM PUT error: ok');
}
async function testUploadViaVMPostError() {
installFetch(() => ({ ok: true, json: async () => ({ ok: false, error: 'Session not found' }) }));
const files = [new File(['a'], 'a.txt')];
const p = uploadViaVM(files, 'https://vm/', { session: '' });
await tick();
lastXHR.status = 200; lastXHR.onload();
const res = await p;
assert.equal(res.ok, false);
assert.ok(res.error.includes('Ошибка передачи ссылок'));
console.log(' uploadViaVM POST error: ok');
}
async function testUploadViaVMProgress() {
installFetch(() => ({ ok: true, json: async () => ({ ok: true, session: 's', count: 1 }) }));
const statuses = [];
const texts = [];
const files = [new File(['abc'], 'a.txt')];
const p = uploadViaVM(files, 'https://vm/', {
session: '',
onStatus(k, html) { statuses.push({ k, html }); },
onUploadStatus(t) { texts.push(t); },
}); });
await tick(); const files = [
lastXHR.status = 200; lastXHR.onload(); { id: 'one', name: 'one.pdf', path: 'one.pdf', file: new File(['1'], 'one.pdf') },
await p; { id: 'two', name: 'two.pdf', path: 'archive.zip/folder/two.pdf', file: new File(['2'], 'two.pdf') },
assert.ok(statuses.some(s => s.html.includes('✓'))); ];
assert.ok(texts.some(t => t.includes('Загрузка на ВМ'))); const completed = [];
assert.ok(texts.some(t => t.includes('Передача ссылок'))); const previous = lastXHR;
console.log(' uploadViaVM progress/статусы: ok'); const request = uploadViaVM(files, {
vmUploadUrl: 'https://vm.example/contracts-upload/',
backendUploadUrl: '/api/upload_refs',
getExtraPayload: (entry) => ({
batch_id: 'batch-1',
contract_id: 'contract-1',
zip_source: entry.id === 'two' ? 'archive.zip' : null,
}),
onFileComplete: (event) => completed.push(event),
});
const firstXHR = await waitForNextXHR(previous);
firstXHR.complete();
const secondXHR = await waitForNextXHR(firstXHR);
secondXHR.complete();
const result = await request;
assert.equal(result.ok, true);
assert.equal(fetchCalls.length, 2);
const firstBody = JSON.parse(fetchCalls[0].options.body);
const secondBody = JSON.parse(fetchCalls[1].options.body);
assert.equal(firstBody.batch_id, 'batch-1');
assert.equal(firstBody.contract_id, 'contract-1');
assert.equal(firstBody.files.length, 1);
assert.equal(secondBody.zip_source, 'archive.zip');
assert.equal(completed.length, 2);
assert.equal(completed[1].result.doc_id, 'doc-2');
assert.equal(completed[1].source.id, 'two');
} }
async function testUploadViaVMSessionPassed() { async function testUploadReportsPutFailure() {
// session из options пробрасывается в POST-тело fetchCalls = [];
installFetch(() => ({ ok: true, json: async () => ({ ok: true, session: 'prev', count: 1 }) })); const previous = lastXHR;
const files = [new File(['a'], 'a.txt')]; const request = uploadViaVM([new File(['x'], 'x.pdf')], {
const p = uploadViaVM(files, 'https://vm/', { session: 'existingsid' }); vmUploadUrl: 'https://vm.example/',
await tick(); });
lastXHR.status = 200; lastXHR.onload(); const xhr = await waitForNextXHR(previous);
await p; xhr.onerror();
const body = JSON.parse(fetchCalls[0].opts.body); const result = await request;
assert.equal(body.session, 'existingsid'); assert.equal(result.ok, false);
console.log(' uploadViaVM проброс session: ok'); assert.equal(fetchCalls.length, 0);
assert.match(result.error, /Ошибка отправки файла/);
} }
// ── прогон ── async function testUploadReportsBackendFailure() {
const TESTS = [ fetchCalls = [];
['putToVm success', testPutToVmSuccess], fetchResponder = async () => ({
['putToVm HTTP error', testPutToVmHttpError], ok: false,
['putToVm network error', testPutToVmNetworkError], status: 502,
['putToVm timeout', testPutToVmTimeout], statusText: 'Bad Gateway',
['putToVm onXHR', testPutToVmOnXHR], json: async () => ({ ok: false, error: 'failed' }),
['uploadViaVM success', testUploadViaVMSuccess], });
['uploadViaVM PUT error', testUploadViaVMPutError], const previous = lastXHR;
['uploadViaVM POST error', testUploadViaVMPostError], const request = uploadViaVM([new File(['x'], 'x.pdf')], {
['uploadViaVM progress', testUploadViaVMProgress], vmUploadUrl: 'https://vm.example/',
['uploadViaVM проброс session', testUploadViaVMSessionPassed], backendUploadUrl: '/api/upload_refs',
});
const xhr = await waitForNextXHR(previous);
xhr.complete();
const result = await request;
assert.equal(result.ok, false);
assert.match(result.error, /HTTP 502/);
}
async function testAbortBeforeUpload() {
const controller = new AbortController();
controller.abort();
const previous = lastXHR;
const result = await uploadViaVM([new File(['x'], 'x.pdf')], {
vmUploadUrl: 'https://vm.example/',
signal: controller.signal,
});
assert.equal(result.aborted, true);
assert.equal(lastXHR, previous);
}
const tests = [
['PUT via WebDAV', testPutToVmSuccess],
['per-file extra payload and sink result', testUploadIncludesPerFilePayloadAndResult],
['PUT failure', testUploadReportsPutFailure],
['backend failure', testUploadReportsBackendFailure],
['abort before upload', testAbortBeforeUpload],
]; ];
let failed = 0; for (const [name, test] of tests) {
for (const [name, fn] of TESTS) { await test();
try { await fn(); console.log('PASS ' + name); } console.log(`PASS ${name}`);
catch (e) { failed++; console.error('FAIL ' + name + ': ' + e.message); }
} }
if (failed) { console.error(failed + ' тестов упало'); process.exit(1); }
console.log('Все тесты слоя 2 (фронт) прошли');
+68 -13
View File
@@ -1,23 +1,78 @@
// putToVm — PUT одного файла на ВМ-буфер (XHR, сырое тело). /**
// onProgress(pct) — прогресс загрузки. Разрешается при HTTP 2xx. * putToVm — отправка одного файла в буфер методом HTTP PUT (сырое бинарное тело).
*
* @param {Blob|File} file Файл для отправки.
* @param {string} url Полный целевой URL в буфере.
* @param {Object} options Опции отправки:
* @param {function(number, number, number): void} [options.onProgress] Колбэк прогресса (pct, loaded, total).
* @param {function(XMLHttpRequest): void} [options.onXHR] Колбэк регистрации XHR для прямого контроля.
* @param {AbortSignal} [options.signal] Сигнал прерывания запроса (AbortController.signal).
* @param {number} [options.timeoutMs=300000] Таймаут в миллисекундах (по умолчанию 300с).
* @returns {Promise<void>} Разрешается при ответе 2xx, отклоняется при ошибке или прерывании.
*/
export function putToVm(file, url, options = {}) { export function putToVm(file, url, options = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
if (options.onXHR) options.onXHR(xhr); // регистрация для отмены (abort)
if (options.onXHR) {
options.onXHR(xhr);
}
const abortHandler = () => {
xhr.abort();
reject(new DOMException('Upload aborted', 'AbortError'));
};
if (options.signal) {
if (options.signal.aborted) {
reject(new DOMException('Upload aborted', 'AbortError'));
return;
}
options.signal.addEventListener('abort', abortHandler, { once: true });
}
xhr.open('PUT', url); xhr.open('PUT', url);
xhr.timeout = options.timeoutMs || 300000; // 300с: большие файлы xhr.timeout = options.timeoutMs || 300000;
xhr.upload.onprogress = function(e) {
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && options.onProgress) { if (e.lengthComputable && options.onProgress) {
options.onProgress(Math.round(e.loaded / e.total * 100)); const pct = Math.round((e.loaded / e.total) * 100);
options.onProgress(pct, e.loaded, e.total);
} }
}; };
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) resolve(); xhr.onload = () => {
else reject(new Error('ВМ: HTTP ' + xhr.status)); if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`Буфер: HTTP ${xhr.status} ${xhr.statusText}`));
}
}; };
xhr.onerror = function() { reject(new Error('Сеть (ВМ)')); };
xhr.ontimeout = function() { reject(new Error('Таймаут 300с (ВМ)')); }; xhr.onerror = () => {
xhr.send(file); // сырое тело файла if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
reject(new Error('Сетевая ошибка при отправке в буфер'));
};
xhr.ontimeout = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
reject(new Error(`Таймаут ожидания ответа буфера (${Math.round(xhr.timeout / 1000)}с)`));
};
xhr.onabort = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
reject(new DOMException('Upload aborted', 'AbortError'));
};
xhr.send(file);
}); });
} }
+185 -44
View File
@@ -1,59 +1,200 @@
// uploadViaVM — слой 2 (фронт): закачать файлы через ВМ-буфер. /**
// Шаг 1: PUT каждого файла на ВМ (token-ключ в URL). Шаг 2: POST /api/upload_refs. * uploadViaVM — Слой 2 (Фронтенд): пофайловый транзит через ВМ-буфер в сессию бэкенда.
// *
// files: File[] — ТОЛЬКО учитываемые (без сверхлимитных). * Архитектура потокового транзита:
// vmUploadUrl: базовый URL ВМ-буфера (напр. "https://.../drhider-upload/"). * Для каждого файла k:
// options: { * 1. Браузер отправляет файл k методом PUT в буфер на ВМ.
// session, // текущий sid (или '') * 2. Браузер сразу делает POST /api/upload_refs для одного файла k.
// apiBase, // базовый путь бэка, по умолчанию '' (тот же origin) * 3. Бэкенд забирает файл исходящим GET в память RAM и сразу шлёт DELETE в буфер.
// onUploadStatus, // (text) — сообщение статуса * 4. Файл удалён из буфера ВМ, память чиста.
// onStatus, // (k, html) — статус в таблице для k-го файла * 5. Браузер переходит к файлу k+1.
// } */
// Returns: Promise<{ok, session, count} | {ok:false, error}>
import { putToVm } from './put_to_vm.js'; import { putToVm } from './put_to_vm.js';
import { fs } from '../table/fs.js'; import { fs } from '../table/fs.js';
export async function uploadViaVM(files, vmUploadUrl, options = {}) { function generateUuid() {
const token = crypto.randomUUID(); if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
const refs = []; // {name, size, url} — для POST /api/upload_refs return crypto.randomUUID();
const total = files.length; }
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* Нормализует элемент списка файлов (поддерживает как File, так и объект из FilePicker.getFiles()).
*/
function normalizeFileEntry(item) {
if (item instanceof File || item instanceof Blob) {
return { name: item.name || 'unnamed', size: item.size, file: item, path: item.name || '', source: item };
}
if (item && item.file) {
return {
name: item.name || item.file.name || 'unnamed',
size: item.size ?? item.file.size ?? 0,
file: item.file,
path: item.path || item.name || '',
source: item,
};
}
throw new Error('Некорректный объект файла');
}
/**
* Выполняет пофайловую транзитную загрузку файлов в сессию бэкенда через буфер.
*
* @param {Array<File|{name: string, size: number, file: File, path?: string}>} files Список файлов.
* @param {Object} options Параметры загрузки:
* @param {string} options.vmUploadUrl Базовый URL буфера (обязателен).
* @param {string} [options.backendUploadUrl='/api/upload_refs'] URL эндпоинта приёма ссылок.
* @param {string} [options.session=''] Идентификатор существующей сессии (если есть).
* @param {AbortSignal} [options.signal] Сигнал отмены загрузки.
* @param {function(string): void} [options.onStatus] Колбэк общего текстового статуса.
* @param {function(number, string): void} [options.onFileStatus] Колбэк обновления статуса конкретного файла (индекс, текст/html).
* @param {function(Object): void} [options.onProgress] Колбэк прогресса текущего файла ({ index, total, pct, speed }).
* @param {function(Object): void} [options.onFileComplete] Колбэк обработки файла; включает result ответа sink.
* @param {function(Object, number): Object} [options.getExtraPayload] Дополнительные поля POST для файла.
* @param {Object} [options.extraPayload] Общие дополнительные поля POST.
* @returns {Promise<{ok: boolean, session?: string, count?: number, error?: string, aborted?: boolean}>}
*/
export async function uploadViaVM(files, options = {}) {
if (!options.vmUploadUrl) {
return { ok: false, error: 'Параметр options.vmUploadUrl обязателен' };
}
const normalizedFiles = Array.from(files).map(normalizeFileEntry);
const total = normalizedFiles.length;
if (total === 0) {
return { ok: true, session: options.session || '', count: 0 };
}
const backendUrl = options.backendUploadUrl || (options.apiBase || '') + '/api/upload_refs';
const vmBase = options.vmUploadUrl.endsWith('/') ? options.vmUploadUrl : `${options.vmUploadUrl}/`;
let currentSession = options.session || '';
let totalAdded = 0;
for (let k = 0; k < total; k++) { for (let k = 0; k < total; k++) {
const f = files[k]; if (options.signal?.aborted) {
const vmUrl = vmUploadUrl + token + '_' + k; // имя файла в URL не несём (токен-ключ) return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
if (options.onUploadStatus) {
options.onUploadStatus('Загрузка на ВМ (этап 1/2) ' + (k + 1) + '/' + total + ': ' + f.name);
} }
const { name, size, file, source } = normalizedFiles[k];
const fileKey = `${generateUuid()}_${k}`;
const fileUrl = `${vmBase}${fileKey}`;
if (options.onStatus) {
options.onStatus(`Загрузка в буфер (${k + 1}/${total}): ${name}`);
}
if (options.onFileStatus) {
options.onFileStatus(k, '⏳ В буфер...');
}
// Этап 1: Отправка одного файла в буфер
const t0 = performance.now();
try { try {
const t0 = performance.now(); await putToVm(file, fileUrl, {
await putToVm(f, vmUrl, { signal: options.signal,
onProgress(pct) {
if (options.onStatus) options.onStatus(k, '<span style="color:#2563eb">⏳ ' + pct + '%</span>');
},
onXHR: options.onXHR, onXHR: options.onXHR,
onProgress(pct, loaded, fileTotal) {
const elapsed = (performance.now() - t0) / 1000;
const speed = elapsed > 0 ? (loaded / elapsed) : 0;
if (options.onFileStatus) {
options.onFileStatus(k, `⏳ ${pct}% (${fs(speed)}/s)`);
}
if (options.onProgress) {
options.onProgress({ index: k, total, pct, speed, loaded, fileTotal, name });
}
},
}); });
const elapsed = (performance.now() - t0) / 1000;
const speed = f.size / elapsed;
if (options.onStatus) options.onStatus(k, '<span style="color:#22c55e">✓ ' + fs(speed) + '/s</span>');
refs.push({ name: f.name, size: f.size, url: vmUrl });
} catch (err) { } catch (err) {
if (options.onStatus) options.onStatus(k, '<span style="color:#ef4444">✗</span>'); if (options.signal?.aborted) {
return { ok: false, error: 'Ошибка загрузки на ВМ: ' + err.message }; return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
}
if (options.onFileStatus) {
options.onFileStatus(k, '✗ Ошибка буфера');
}
return {
ok: false,
error: `Ошибка отправки файла ${name} в буфер: ${err.message}`,
failedIndex: k,
session: currentSession,
};
}
// Этап 2: Вызов /api/upload_refs для немедленного переноса файла с буфера в RAM сессии
if (options.onStatus) {
options.onStatus(`Приём сервисом (${k + 1}/${total}): ${name}`);
}
if (options.onFileStatus) {
options.onFileStatus(k, '⚡ В сессию...');
}
try {
const resp = await fetch(backendUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...(typeof options.getExtraPayload === 'function'
? options.getExtraPayload(source, k) || {}
: options.extraPayload || {}),
session: currentSession,
files: [{ name, size, url: fileUrl }],
}),
signal: options.signal,
});
if (!resp.ok) {
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
}
const data = await resp.json();
if (!data.ok) {
throw new Error(data.error || 'Бэкенд вернул ошибку');
}
const result = Array.isArray(data.results) ? data.results[0] : undefined;
if (Array.isArray(data.results) && !result) {
throw new Error('Бэкенд не вернул результат обработки файла');
}
currentSession = data.session || currentSession;
totalAdded = data.count ?? (totalAdded + 1);
if (options.onFileStatus) {
options.onFileStatus(k, '✓ Доставлен');
}
if (options.onFileComplete) {
options.onFileComplete({
fileIndex: k,
name,
size,
session: currentSession,
totalAdded,
result,
source,
});
}
} catch (err) {
if (options.signal?.aborted) {
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
}
if (options.onFileStatus) {
options.onFileStatus(k, '✗ Ошибка приёма');
}
return {
ok: false,
error: `Ошибка приёма файла ${name} бэкендом: ${err.message}`,
failedIndex: k,
session: currentSession,
};
} }
} }
// Шаг 1b: один маленький POST во Flask со ссылками на файлы ВМ (<64КБ) if (options.onStatus) {
if (options.onUploadStatus) options.onUploadStatus('Передача ссылок в сервис…'); options.onStatus(`Завершено. Успешно передано файлов: ${total}`);
try {
const resp = await fetch((options.apiBase || '') + '/api/upload_refs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: options.session || '', files: refs })
});
const data = await resp.json();
if (!data.ok) throw new Error(data.error || 'HTTP ' + resp.status);
return { ok: true, session: data.session, count: data.count || refs.length };
} catch (err) {
return { ok: false, error: 'Ошибка передачи ссылок: ' + err.message };
} }
return { ok: true, session: currentSession, count: totalAdded };
} }
-22
View File
@@ -1,22 +0,0 @@
// decodeZipName — декодирование имени из ZIP: UTF-8-флаг / эвристика (UTF-8 → CP437 → CP866).
export function decodeZipName(bytes, isUtf8) {
if (isUtf8) return new TextDecoder('utf-8').decode(bytes);
// Многие архиваторы пишут имя в UTF-8, но НЕ выставляют UTF-8-флаг (bit 11).
// Сначала строго пробуем UTF-8: если байты — валидный UTF-8 с кириллицей/текстом,
// берём их как есть (иначе декодирование CP437→CP866 превратит их в «╨╣…»-мусор).
try {
const s = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
// Кириллица — точно UTF-8; либо чистый печатаемый текст без управляющих символов.
if (/[\u0400-\u04FF]/.test(s) || !/[^\u0020-\u007e]/.test(s)) return s;
} catch (e) { /* не UTF-8 — legacy (CP437/CP866) */ }
let name;
try { name = new TextDecoder('ibm437').decode(bytes); }
catch (e) { name = new TextDecoder('utf-8').decode(bytes); }
// Кириллица из 1С (CP866) — перекодировать, если имя пришло как CP437-мусор
if (/[^\x00-\x7f]/.test(name)) {
try { name = new TextDecoder('ibm866').decode(bytes); }
catch (e) { /* оставить как есть */ }
}
return name;
}
-11
View File
@@ -1,11 +0,0 @@
// dosToMs — преобразование DOS-даты/времени (ZIP) в миллисекунды (unix epoch).
export function dosToMs(date, time) {
const year = 1980 + ((date >> 9) & 0x7f);
const month = (date >> 5) & 0x0f;
const day = date & 0x1f;
const hour = (time >> 11) & 0x1f;
const min = (time >> 5) & 0x3f;
const sec = (time & 0x1f) * 2;
return new Date(year, month - 1, day, hour, min, sec).getTime();
}
-8
View File
@@ -1,8 +0,0 @@
// inflateRaw — распаковка deflate-raw (метод 8) через нативный DecompressionStream.
export async function inflateRaw(bytes) {
const ds = new DecompressionStream('deflate-raw');
const stream = new Blob([bytes]).stream().pipeThrough(ds);
const ab = await new Response(stream).arrayBuffer();
return new Uint8Array(ab);
}
+122 -19
View File
@@ -1,22 +1,125 @@
// listZipFiles — рекурсивно достать из ZIP только документы (вложенные zip — разворачиваются). // Построить дерево ZIP с файлами только разрешённых расширений.
import { unzipSync } from 'fflate';
import { rebaseTree } from '../table/rebase_tree.js';
import { parseZip } from './parse_zip.js'; const DEFAULT_LIMITS = {
maxEntries: 1000,
maxTotalBytes: 100 * 1024 * 1024,
maxEntryBytes: 50 * 1024 * 1024,
maxDepth: 20,
};
export async function listZipFiles(file, allowedExt) { /** Возвращает true, если имя заканчивается одним из разрешённых расширений. */
const buf = new Uint8Array(await file.arrayBuffer()); function extensionAllowed(name, allowedExt) {
const entries = await parseZip(buf); const lowerName = name.toLowerCase();
const out = []; return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
// Из ZIP вытаскиваем только документы. Всё прочее (изображения и т.п.) пропускаем. }
for (const e of entries) {
if (e.isDir) continue; /** Создаёт browser File из байтов ZIP entry с базовым именем файла. */
const low = e.name.toLowerCase(); function makeFile(data, name) {
if (low.endsWith('.zip')) { return new File([data], name.split('/').at(-1));
const sub = new File([e.data], e.name, { lastModified: e.dosMs }); }
out.push(...(await listZipFiles(sub, allowedExt)));
} else if (allowedExt.some(ext => low.endsWith(ext))) { /**
out.push(new File([e.data], e.name, { lastModified: e.dosMs })); * Валидирует путь ZIP entry до его использования в дереве.
} *
// иначе — не документ, пропускаем * @param {string} entryName Сырой путь из центрального каталога ZIP.
} * @returns {string[]|null} Безопасные сегменты пути или null для опасной записи.
return out; *
* Отклоняются абсолютные пути, backslash, пустые сегменты, `.` и `..`.
* Это предотвращает traversal и появление ложных групп в UI.
*/
function safeEntryParts(entryName) {
if (!entryName || entryName.startsWith('/') || entryName.includes('\\')) return null;
const parts = entryName.split('/');
if (parts.some((part) => !part || part === '.' || part === '..')) return null;
return parts;
}
/** Создаёт единый узел file, folder или zip с уникальным id и начальным состоянием раскрытия. */
function node(kind, name, path, children = [], file = null) {
return { id: crypto.randomUUID(), kind, name, path, children, file, expanded: kind === 'file' };
}
/** Вставляет leaf или вложенное дерево по сегментам пути, создавая folder-узлы. */
function addPath(root, parts, fileNode) {
let current = root;
parts.forEach((part, index) => {
const last = index === parts.length - 1;
let child = current.children.find((item) => item.name === part);
if (!child) {
child = last
? fileNode
: node('folder', part, `${current.path}/${part}`);
current.children.push(child);
}
current = child;
});
}
/**
* Рекурсивно читает байтовый массив 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, limits, budget) {
// Ограничение глубины защищает браузер от бесконечной/чрезмерной рекурсии.
if (depth > limits.maxDepth) throw new Error('Слишком глубокая вложенность ZIP');
const entries = unzipSync(data, {
filter: (entry) => {
budget.entries += 1;
if (budget.entries > limits.maxEntries) throw new Error('Слишком много ZIP entries');
const lowerName = entry.name.toLowerCase();
if (!extensionAllowed(lowerName, allowedExt) && !lowerName.endsWith('.zip')) return false;
if (entry.originalSize > limits.maxEntryBytes) return false;
if (budget.totalBytes + entry.originalSize > limits.maxTotalBytes) {
throw new Error('Превышен суммарный размер распакованных ZIP entries');
}
budget.totalBytes += entry.originalSize;
return true;
},
});
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, limits, budget);
if (nested) {
rebaseTree(nested, zipName);
nested.name = parts.at(-1);
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));
}
}
return root.children.length ? root : null;
}
/**
* Публично читает выбранный ZIP-файл и возвращает его фильтрованное дерево.
*
* @param {File} file Browser File с ZIP-содержимым.
* @param {string[]} allowedExt Разрешённые расширения.
* @param {object} customLimits Ограничения распаковки.
* @returns {Promise<object|null>} Корневой zip-узел или null для пустого результата.
*/
export async function listZipFiles(file, allowedExt, customLimits = {}) {
const data = new Uint8Array(await file.arrayBuffer());
const limits = { ...DEFAULT_LIMITS, ...customLimits };
return listEntries(data, file.name, allowedExt, 0, limits, { entries: 0, totalBytes: 0 });
} }
-50
View File
@@ -1,50 +0,0 @@
// parseZip — разбор ZIP: центральный каталог → записи {name, data, dosMs, isDir}.
// Поддерживаются методы 0 (store) и 8 (deflate). Без внешних библиотек.
import { decodeZipName } from './decode_zip_name.js';
import { inflateRaw } from './inflate_raw.js';
import { dosToMs } from './dos_to_ms.js';
export async function parseZip(buf) {
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
let eocd = -1;
for (let i = buf.length - 22; i >= 0; i--) {
if (dv.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
}
if (eocd < 0) throw new Error('Не ZIP');
const cdSize = dv.getUint32(eocd + 12, true);
const cdOffset = dv.getUint32(eocd + 16, true);
const entries = [];
let pos = cdOffset;
const cdEnd = cdOffset + cdSize;
while (pos < cdEnd) {
if (dv.getUint32(pos, true) !== 0x02014b50) break;
const flags = dv.getUint16(pos + 8, true);
const method = dv.getUint16(pos + 10, true);
const modTime = dv.getUint16(pos + 12, true);
const modDate = dv.getUint16(pos + 14, true);
const compSize = dv.getUint32(pos + 20, true);
const nameLen = dv.getUint16(pos + 28, true);
const extraLen = dv.getUint16(pos + 30, true);
const commentLen = dv.getUint16(pos + 32, true);
const localOffset = dv.getUint32(pos + 42, true);
const nameBytes = buf.slice(pos + 46, pos + 46 + nameLen);
const name = decodeZipName(nameBytes, (flags & 0x800) !== 0);
const lhNameLen = dv.getUint16(localOffset + 26, true);
const lhExtraLen = dv.getUint16(localOffset + 28, true);
const dataStart = localOffset + 30 + lhNameLen + lhExtraLen;
const comp = buf.slice(dataStart, dataStart + compSize);
let data;
if (method === 0) data = comp;
else if (method === 8) data = await inflateRaw(comp);
else throw new Error('Метод сжатия ' + method + ' не поддерживается');
entries.push({ name, data, dosMs: dosToMs(modDate, modTime), isDir: name.endsWith('/') });
pos += 46 + nameLen + extraLen + commentLen;
}
return entries;
}