Integrate Obfuscator file picker into Contracts
Deploy contracts-flask / validate (push) Canceled after 0s
Deploy contracts-flask / validate (push) Canceled after 0s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -21,13 +21,15 @@
|
||||
|
||||
## 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`.
|
||||
- `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()` завершает загрузку и обновляет состояние.
|
||||
- `renderFiles(state)` отображает список файлов, группируя вложения по `zip_source`.
|
||||
- `syncDB()` синхронизирует состав файлов в БД.
|
||||
Связка с приложением находится в [`site/static/app.js`](../site/static/app.js) и [`site/static/files.js`](../site/static/files.js):
|
||||
|
||||
- `onPickerFilesChange()` синхронизирует leaf-файлы дерева с `state.files`, вычисляет `zip_source` как путь до последнего ZIP в цепочке и ставит новые файлы в последовательную очередь.
|
||||
- `uploadPickerEntry()` вызывает `FilePicker.uploadViaVM()` для одного файла: PUT в VM-буфер, затем POST `/api/upload_refs` с `batch_id`, `contract_id` и `zip_source`.
|
||||
- Результат 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`.
|
||||
|
||||
|
||||
+16
-12
@@ -34,17 +34,9 @@ def transaction():
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Создать/пересоздать БД + схему. Вызывается при старте и после cleanup."""
|
||||
"""Create the database schema without deleting persisted data."""
|
||||
global _db_session_key
|
||||
_db_session_key = time.time()
|
||||
|
||||
# Удалить старый файл + WAL-сателлиты
|
||||
for f in (DB_PATH, DB_PATH + "-wal", DB_PATH + "-shm"):
|
||||
try:
|
||||
os.remove(f)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
_db_session_key = time.time_ns()
|
||||
conn = get_conn()
|
||||
_create_schema(conn)
|
||||
return conn
|
||||
@@ -147,8 +139,20 @@ def _create_schema(conn):
|
||||
|
||||
|
||||
def cleanup_db():
|
||||
"""Удалить БД полностью. Вызывает init_db() для создания новой."""
|
||||
init_db()
|
||||
"""Explicitly delete the database and recreate an empty schema."""
|
||||
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():
|
||||
|
||||
+42
-34
@@ -1,7 +1,7 @@
|
||||
"""API blueprint — groups, documents, supplements, sync, cleanup, spec-current, chat."""
|
||||
from flask import Blueprint, request, jsonify
|
||||
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 config import LLM_URL, LLM_KEY, LLM_MODEL
|
||||
import httpx
|
||||
@@ -38,47 +38,65 @@ def api_document(doc_id):
|
||||
|
||||
@api_bp.route("/api/documents/<doc_id>", methods=["DELETE"])
|
||||
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,))
|
||||
for s in (supps or []):
|
||||
contract_ids = {row["contract_id"] for row in supps}
|
||||
for supp in supps:
|
||||
execute(
|
||||
"""DELETE FROM spec_current WHERE contract_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 supplements WHERE id = %s", (s["id"],))
|
||||
execute("DELETE FROM spec_events WHERE supplement_id = %s", (supp["id"],))
|
||||
execute("DELETE FROM supplements WHERE id = %s", (supp["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 ─────────────────────────────────────────────────────────
|
||||
|
||||
@api_bp.route("/api/sync", methods=["POST"])
|
||||
def api_sync():
|
||||
"""Удалить документы, не входящие в keep_ids."""
|
||||
body = request.get_json()
|
||||
keep_ids = set(body.get("keep_ids", []))
|
||||
"""Remove documents absent from keep_ids in the specified batch only."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
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:
|
||||
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
|
||||
for d in (docs or []):
|
||||
if d["id"] in keep_ids:
|
||||
contract_ids = set()
|
||||
keep_ids = set(keep_ids)
|
||||
with transaction():
|
||||
for doc in docs:
|
||||
if doc["id"] in keep_ids:
|
||||
continue
|
||||
supps = query("SELECT id, contract_id FROM supplements WHERE document_id = %s", (d["id"],))
|
||||
for s in (supps or []):
|
||||
execute(
|
||||
"""DELETE FROM spec_current WHERE contract_id = %s
|
||||
AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)""",
|
||||
(s["contract_id"], s["id"]),
|
||||
)
|
||||
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"],))
|
||||
contract_ids.update(_delete_document(doc["id"]))
|
||||
deleted += 1
|
||||
|
||||
execute("DELETE FROM contracts WHERE id NOT IN (SELECT DISTINCT contract_id FROM supplements)")
|
||||
_delete_orphan_contracts(contract_ids)
|
||||
return jsonify(ok=True, deleted=deleted)
|
||||
|
||||
|
||||
@@ -170,13 +188,3 @@ def chat():
|
||||
return jsonify(OK=True, ANSWER=answer)
|
||||
except Exception as 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")
|
||||
|
||||
@@ -9,6 +9,10 @@ _UPLOAD_FRONTEND = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"upload", "frontend",
|
||||
)
|
||||
_FILE_PICKER_DIST = os.path.join(
|
||||
os.path.dirname(os.path.dirname(_UPLOAD_FRONTEND)),
|
||||
"site", "static", "dist",
|
||||
)
|
||||
|
||||
|
||||
@pages_bp.route("/")
|
||||
@@ -25,3 +29,9 @@ def architect():
|
||||
def upload_frontend(filename):
|
||||
"""Отдать статику ES-модулей upload/frontend (для клиентского ZIP)."""
|
||||
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
@@ -1,45 +1,51 @@
|
||||
// ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔
|
||||
// Contracts App v2.0 — всё на Flask, ВМ больше нет
|
||||
// Contracts App v2.0 — backend на Flask; ВМ используется только как upload buffer.
|
||||
var VM_API = '';
|
||||
// ВМ-буфер загрузки (паттерн drhider): браузер кладёт файл сюда (WebDAV, мимо шлюза),
|
||||
// бэк сам тянет его по /api/upload_refs. Origin должен быть в CORS на nginx ВМ.
|
||||
var VM_UPLOAD_URL = 'https://contracts.kube5s.ru/contracts-upload/';
|
||||
// SITE_URL удалён (Фаза 4) — не использовался
|
||||
|
||||
var fileInput = document.getElementById('fileInput');
|
||||
var fileTable = document.getElementById('fileTable');
|
||||
// state.files — теперь в state.js (Фаза 0: state + render)
|
||||
// state.contractId — теперь в state.js (Фаза 0: state + render)
|
||||
// state.batchId — теперь в state.js (Фаза 0: state + render)
|
||||
// (batchId = crypto.randomUUID() — уникальный ID сессии для классификации)
|
||||
|
||||
// Прогрев upstream + автоочистка при загрузке страницы
|
||||
// Прогрев приложения при загрузке страницы.
|
||||
// ⚠️ Без прогрева первый POST может упасть с ERR_CONNECTION_RESET (MTU/Geneve)
|
||||
(async function init() {
|
||||
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).
|
||||
*
|
||||
* ПАТТЕРН: 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) показывает ВСЁ состояние в любой момент.
|
||||
* Обновить отображение picker и текущий шаг pipeline после изменения state.
|
||||
*/
|
||||
function render(state) {
|
||||
renderFiles(state);
|
||||
renderStepper(state);
|
||||
}
|
||||
|
||||
// syncDB, statusToHTML, renderFiles, toggleClassifyDetail, uploadFile, refreshSupps
|
||||
// → вынесены в files.js (Фаза 1)
|
||||
if (!window.FilePicker) throw new Error('Не загружен бандл FilePicker');
|
||||
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) ──────────────────
|
||||
|
||||
@@ -97,36 +103,13 @@ function resetStepper(fromId) {
|
||||
}
|
||||
// Утилиты (formatSize, formatDate, moveUp, moveDown, escHtml, fmtDate) —
|
||||
// вынесены в app_utils.js для облегчения анализа и отладки.
|
||||
// statusToHTML, renderFiles → files.js (Фаза 1)
|
||||
// Picker rows are rendered by the FilePicker integration in files.js.
|
||||
|
||||
// ── Раскрыть/скрыть результат классификации под строкой файла ──
|
||||
// toggleClassifyDetail → files.js (Фаза 1)
|
||||
|
||||
// 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() {
|
||||
var btn = document.getElementById('classifyBtn');
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
/**
|
||||
* Форматирует размер файла в человекочитаемый вид.
|
||||
* Используется в renderTable() для колонки "Размер".
|
||||
* Используется в модальном окне документа.
|
||||
*/
|
||||
function formatSize(bytes) {
|
||||
if (!bytes || bytes === 0) return '—';
|
||||
@@ -17,7 +17,7 @@ function formatSize(bytes) {
|
||||
|
||||
/**
|
||||
* Форматирует timestamp в российскую дату + время.
|
||||
* Используется в renderTable() для колонки "Изменён".
|
||||
* Используется в модальном окне документа.
|
||||
*/
|
||||
function formatDate(ts) {
|
||||
if (!ts) return '—';
|
||||
@@ -33,15 +33,8 @@ function formatDate(ts) {
|
||||
* Если очередь опустела — сбрасывает state.contractId.
|
||||
*/
|
||||
function removeFile(i) {
|
||||
state.files.splice(i, 1);
|
||||
if (state.files.length === 0) state.contractId = null;
|
||||
render(state);
|
||||
// Синхронизировать БД с таблицей
|
||||
if (typeof syncDB === 'function') syncDB();
|
||||
// Сбросить прогресс при изменении состава файлов
|
||||
if (typeof resetStepper === 'function') resetStepper('stepUpload');
|
||||
// Разблокировать кнопку классификации при изменении состава файлов
|
||||
if (typeof showClassifyBtn === 'function') showClassifyBtn();
|
||||
var file = state.files[i];
|
||||
if (file && filePicker) filePicker.remove(file.pickerId);
|
||||
}
|
||||
window.removeFile = removeFile;
|
||||
|
||||
|
||||
Vendored
+21
@@ -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.
|
||||
Vendored
+1513
File diff suppressed because it is too large
Load Diff
+253
-264
@@ -3,134 +3,48 @@
|
||||
*
|
||||
* ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔⛔⛔
|
||||
*
|
||||
* v2.0.3: честный счётчик ⏳ соединение... Nс вместо фейкового ↑N%
|
||||
* Проверено в бою 2026-07-16. Любое изменение = риск сломать загрузку.
|
||||
* FilePicker owns selection/tree rendering; this module connects it to the
|
||||
* Contracts batch, upload sink, and document pipeline.
|
||||
*
|
||||
* КЛЮЧЕВЫЕ ФУНКЦИИ (не трогать):
|
||||
* - 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 — чистая функция рендеринга.
|
||||
* Состав и обработка документов синхронизируются с FilePicker в app.js.
|
||||
*/
|
||||
function statusToHTML(st) {
|
||||
if (!st || !st.kind) return '';
|
||||
switch (st.kind) {
|
||||
case 'connecting': return '⏳ соединение... ' + (st.elapsed || 0) + 'с';
|
||||
case 'converting': return '⏳ конвертация... ' + (st.elapsed || 0) + 'с';
|
||||
case 'uploading': return '⏳ отправка... ' + (st.elapsed || 0) + 'с';
|
||||
case 'uploaded': return '<span class="status-ok">✓</span>';
|
||||
case 'unzipping': return '⏳ распаковка...';
|
||||
case 'parsing': return '⏳ парсинг...';
|
||||
case 'parsed': return '<span class="status-ok">✓ ' + (st.count || 0) + ' эл.' + (st.elapsed ? ' (' + st.elapsed + 'с)' : '') + '</span>';
|
||||
case 'error': return '<span class="status-err">✗ ' + escHtml(st.text || 'Неизвестная ошибка') + '</span>';
|
||||
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 '';
|
||||
}
|
||||
}
|
||||
|
||||
var filePicker = null;
|
||||
var uploadQueue = Promise.resolve();
|
||||
var uploadTasks = new Map();
|
||||
var queuedUploads = new Set();
|
||||
var pendingUploads = 0;
|
||||
var FILE_PICKER_LIMITS = {
|
||||
maxEntries: 1000,
|
||||
maxTotalBytes: 500 * 1024 * 1024,
|
||||
maxEntryBytes: 50 * 1024 * 1024,
|
||||
maxDepth: 20,
|
||||
};
|
||||
|
||||
/**
|
||||
* renderFiles(state) — Рендер таблицы файлов (бывший renderTable, Фаза 1).
|
||||
*
|
||||
* Читает state.files, генерирует HTML для #fileTable.
|
||||
* Использует statusToHTML() для рендеринга статусов (чистые данные → HTML).
|
||||
* Вызывает lucide.createIcons() для иконок.
|
||||
*
|
||||
* Вход: state (весь объект состояния, но читает только state.files).
|
||||
* Выход: мутирует DOM (fileTable.innerHTML).
|
||||
* renderFiles(state) — обновить отображение дерева и статусов picker.
|
||||
*/
|
||||
function renderFiles(state) {
|
||||
if (state.files.length === 0) {
|
||||
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();
|
||||
if (filePicker) filePicker.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* syncDB() — Синхронизация БД с текущим состоянием файлов.
|
||||
*
|
||||
* Отправляет на бэкенд список doc_id, которые должны остаться в БД.
|
||||
* Всё, чего нет в state.files, будет удалено из БД (cascade).
|
||||
* Вызывается после каждого изменения состава файлов (removeFile, upload).
|
||||
* Синхронизирует keep_ids только для текущего batch_id.
|
||||
*/
|
||||
async function syncDB() {
|
||||
var keepIds = state.files.map(function(f) { return f.doc_id; }).filter(Boolean);
|
||||
try {
|
||||
await fetch(VM_API + '/api/sync', {
|
||||
var keepIds = Array.from(new Set(state.files.map(function(f) { return f.doc_id; }).filter(Boolean)));
|
||||
var response = await fetch(VM_API + '/api/sync', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({keep_ids: keepIds})
|
||||
body: JSON.stringify({batch_id: state.batchId, keep_ids: keepIds})
|
||||
});
|
||||
} catch(e) { /* сервер недоступен — не критично */ }
|
||||
var data = await response.json();
|
||||
if (!response.ok || !data.ok) {
|
||||
throw new Error(data.error || 'Не удалось синхронизировать документы');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,7 +60,47 @@ async function syncDB() {
|
||||
window.toggleClassifyDetail = async function(i) {
|
||||
var detailRow = document.getElementById('detail_' + 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') {
|
||||
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.
|
||||
*
|
||||
@@ -291,25 +186,6 @@ async function refreshSupps() {
|
||||
} 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).
|
||||
*
|
||||
@@ -338,7 +214,7 @@ function applyParseResult(entry, parsed, elapsed) {
|
||||
/**
|
||||
* finalizeUpload() — Завершение цикла загрузки (Фаза 1).
|
||||
*
|
||||
* Вызывается ПОСЛЕ обработки всех файлов в onFilesSelected().
|
||||
* Вызывается после завершения очереди выбранных файлов.
|
||||
* 1. Обновляет supplement_id/type через refreshSupps()
|
||||
* 2. Отмечает шаг «Загрузка» как готовый
|
||||
* 3. Активирует шаг «Классификация»
|
||||
@@ -346,13 +222,17 @@ function applyParseResult(entry, parsed, elapsed) {
|
||||
* 5. Показывает кнопки (LLM, классификация)
|
||||
*/
|
||||
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 syncDB();
|
||||
stepDone('stepUpload');
|
||||
stepActive('stepClassify');
|
||||
syncDB();
|
||||
|
||||
fileInput.value = '';
|
||||
fileInput.disabled = false;
|
||||
|
||||
var llmBtn = document.getElementById('llmBtn');
|
||||
llmBtn.style.display = 'flex';
|
||||
@@ -361,79 +241,188 @@ async function finalizeUpload() {
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
/**
|
||||
* expandZipClient(f) — клиентское рекурсивное раскрытие ZIP (drhider listZipFiles).
|
||||
* Каждый документ из архива (включая вложенные zip) добавляется в state.files
|
||||
* с zip_source = имя архива. Fallback: если раскрыть не удалось — архив как есть.
|
||||
*/
|
||||
async function expandZipClient(f) {
|
||||
var nested = [];
|
||||
function zipSourceFromPath(path) {
|
||||
var parts = String(path || '').split('/');
|
||||
var lastZip = -1;
|
||||
parts.forEach(function(part, index) {
|
||||
if (part.toLowerCase().endsWith('.zip')) lastZip = index;
|
||||
});
|
||||
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 {
|
||||
if (window.listZipFiles) {
|
||||
nested = await window.listZipFiles(f, ['.pdf', '.doc', '.docx']);
|
||||
}
|
||||
} catch (e) {
|
||||
nested = [];
|
||||
}
|
||||
if (!nested || nested.length === 0) {
|
||||
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;
|
||||
return;
|
||||
}
|
||||
for (var z = 0; z < nested.length; z++) {
|
||||
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 });
|
||||
state.files[state.files.length - 1]._pendingFile = nested[z];
|
||||
var response = await window.FilePicker.uploadViaVM([{
|
||||
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('Сервис обработал файл без идентификатора документа');
|
||||
}
|
||||
|
||||
/**
|
||||
* ⛔ НЕ МЕНЯТЬ ⛔ onFilesSelected — все строки сразу, потом загрузка.
|
||||
*
|
||||
* v2.0.2: Фаза 1 — все строки в таблицу. Фаза 2 — загрузка по одной.
|
||||
* Юзер видит таблицу целиком, каждая строка обновляется независимо.
|
||||
*/
|
||||
async function onFilesSelected(newFiles) {
|
||||
if (newFiles.length === 0) return;
|
||||
|
||||
fileInput.disabled = true;
|
||||
resetStepper('stepUpload');
|
||||
|
||||
// Фаза 1: ВСЕ строки в таблицу сразу
|
||||
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;
|
||||
}
|
||||
render(state);
|
||||
|
||||
// Фаза 2: загрузка по одному с обновлением строки
|
||||
for (var j = 0; j < state.files.length; j++) {
|
||||
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' };
|
||||
if (sinkResult.error === 'duplicate' && sinkResult.doc_id) {
|
||||
entry.doc_id = sinkResult.doc_id;
|
||||
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);
|
||||
entry.status = { kind: 'duplicate', text: '⤿ дубликат' };
|
||||
} else if (sinkResult.ok) {
|
||||
entry.doc_id = sinkResult.doc_id;
|
||||
entry.uploaded = true;
|
||||
if (sinkResult.contract_id) state.contractId = sinkResult.contract_id;
|
||||
applyParseResult(entry, sinkResult.parsed);
|
||||
} else {
|
||||
throw new Error(sinkResult.error || 'Не удалось обработать файл');
|
||||
}
|
||||
|
||||
await finalizeUpload();
|
||||
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) {
|
||||
if (queuedUploads.has(entry.pickerId) || uploadTasks.has(entry.pickerId)) return;
|
||||
queuedUploads.add(entry.pickerId);
|
||||
pendingUploads += 1;
|
||||
var run = function() { return uploadPickerEntry(entry); };
|
||||
uploadQueue = uploadQueue.then(run, run).finally(async function() {
|
||||
queuedUploads.delete(entry.pickerId);
|
||||
pendingUploads -= 1;
|
||||
if (pendingUploads === 0) {
|
||||
try {
|
||||
await finalizeUpload();
|
||||
} catch (error) {
|
||||
showPickerError(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
toQueue.forEach(enqueuePickerUpload);
|
||||
if (removed.length) scheduleBatchSync();
|
||||
}
|
||||
|
||||
+23
-19
@@ -68,6 +68,26 @@
|
||||
.text-pane th { font-size: 10px; padding: 4px 6px; }
|
||||
.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; }
|
||||
#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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -90,22 +110,9 @@
|
||||
Загрузка договоров/приложений/спецификаций
|
||||
</div>
|
||||
<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="font-size:11px;color:var(--muted);margin-bottom:6px;">⚠ При совпадении имён — запрос на перезапись (OK / Отмена). Файлы из ZIP-архивов загружаются через тот же поток.</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>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px;">Выберите PDF, DOC или DOCX. ZIP раскрываются рекурсивно; правила из корневого <code>obscure.lst</code> применяются при выборе папки.</div>
|
||||
<div id="filePicker"></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);">
|
||||
<i data-lucide="scale" style="width:16px;height:16px;"></i> Общее сравнение
|
||||
@@ -220,10 +227,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { listZipFiles } from '/upload/zip/list_zip_files.js';
|
||||
window.listZipFiles = listZipFiles;
|
||||
</script>
|
||||
<script src="/file-picker/file-picker.iife.js"></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/files.js?v=2.0.21"></script>
|
||||
|
||||
@@ -46,3 +46,51 @@ class TestGroups:
|
||||
def test_missing_batch(self, client):
|
||||
r = client.get("/api/groups")
|
||||
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
|
||||
|
||||
@@ -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
@@ -30,49 +30,49 @@ upload/
|
||||
|
||||
## Подключение фронта
|
||||
|
||||
Подключить ES-модули (`<script type="module">`) и собрать слой 1:
|
||||
Исходники — ES-модули. Для обычной страницы соберите IIFE-бандл:
|
||||
|
||||
```js
|
||||
import { initUploadTable } from './upload/frontend/table/init_upload_table.js';
|
||||
import { uploadViaVM } from './upload/frontend/upload/upload_via_vm.js';
|
||||
|
||||
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 лежат файлы — запускайте СВОЮ обработку.
|
||||
```sh
|
||||
cd upload/frontend
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
API слоя 1 (`initUploadTable(cfg, els)` → `table`):
|
||||
- `addFiles(File[])` — дедуп + раскрытие ZIP + фильтр + лимиты;
|
||||
- `getFiles()` → `[{name, size, file}]` — **только учитываемые** (без сверхлимитных);
|
||||
- `getOverNames()` → `Set` — имена сверх лимита;
|
||||
- `setStatus(idx, html)` — статус в ячейке таблицы;
|
||||
- `render()` — перерисовать таблицу;
|
||||
- `clear()` — очистить список;
|
||||
- `setBusy(bool)` — заблокировать список на время загрузки/обработки;
|
||||
- `state` — доступ к состоянию (для слоя 3: установить `state.proc` для 3-секционной таблицы).
|
||||
Бандл появится в `site/static/dist/file-picker.iife.js`; Contracts отдаёт его по
|
||||
`/file-picker/file-picker.iife.js`. Инициализация picker:
|
||||
|
||||
```js
|
||||
const picker = window.FilePicker.initFilePicker({
|
||||
mount: '#filePicker',
|
||||
allowedExt: ['.pdf', '.doc', '.docx'],
|
||||
onChange(files) { /* синхронизировать выбранные leaf-файлы с приложением */ },
|
||||
});
|
||||
```
|
||||
|
||||
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
|
||||
`{"session": "...", "files": [{"name", "size", "url"}]}`, тянет каждый файл с ВМ
|
||||
(SSRF-валидация по `vmUploadPrefix`, `_safe_name`, ретраи), кладёт в сессию.
|
||||
Возвращает `{"ok": true, "session", "count"}`.
|
||||
Эндпоинт: `POST {apiPrefix}/upload_refs` — принимает JSON с `session`, `files`
|
||||
и сквозными полями `batch_id`, `contract_id`, `zip_source`; тянет каждый файл с
|
||||
ВМ (SSRF-валидация по `vmUploadPrefix`, безопасное имя, ретраи). Без `sink`
|
||||
файлы кладутся в in-memory сессию и ответ содержит `session`/`count`. С `sink`
|
||||
ответ содержит `results` с результатом каждого файла и обработку/хранение
|
||||
определяет приложение.
|
||||
|
||||
Сессия: `create_session()` → sid; `add_file(sid, name, content)` (лимит 500МБ);
|
||||
`get_files(sid)` → `[(name, bytes), ...]` или `None`. TTL 30 мин (таймер в фоне).
|
||||
|
||||
---
|
||||
|
||||
## Раздача модуля в Flask (обязательно)
|
||||
## Раздача фронтенда
|
||||
|
||||
Фронт-модули — ES-модули, браузер грузит их по HTTP (не через file://).
|
||||
Добавьте route, который отдаёт папку `upload/frontend` (как в drhider `site/routes/main_bp.py`):
|
||||
|
||||
```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, не кода модуля).
|
||||
В `contracts-flask` `pages_bp.py` отдаёт исходные модули по `/upload/<path>`
|
||||
и собранный IIFE по `/file-picker/<path>`. Для другого приложения нужно
|
||||
раздавать его собранный `file-picker.iife.js` по HTTP и подключать `<script src>`.
|
||||
|
||||
---
|
||||
|
||||
## Слой 3: как подключить обработку (опционально)
|
||||
## Слой 3: обработка приложения
|
||||
|
||||
Модуль отдаёт файлы в сессию, а обрабатываете их ВЫ (обфускация, конвертация, ...).
|
||||
После `uploadViaVM` файлы лежат в `res.session`:
|
||||
Без `sink` модуль отдаёт файлы в in-memory сессию, а обрабатываете их ВЫ
|
||||
(обфускация, конвертация и т. п.). После `uploadViaVM` файлы лежат в `res.session`:
|
||||
|
||||
```python
|
||||
files = get_files(res.session) # [(name, bytes), ...]
|
||||
@@ -141,21 +126,9 @@ files = get_files(res.session) # [(name, bytes), ...]
|
||||
store_result(res.session, result_zip) # если нужно отдать результат обратно
|
||||
```
|
||||
|
||||
Для 3-секционной таблицы прогресса (готово/текущий/ожидают) модуль предоставляет
|
||||
`renderProcTable` — достаточно дать слою 3 доступ к `table.state.proc`:
|
||||
|
||||
```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)`.
|
||||
С `sink` файлы не попадают в in-memory сессию: обработчик получает их как
|
||||
`sink(name, content, batch_id=..., contract_id=..., zip_source=...)`, а результат
|
||||
каждой обработки возвращается в `onFileComplete`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ def create_upload_refs_blueprint(cfg: dict, sink=None) -> Blueprint:
|
||||
удаляется с ВМ (best-effort; TTL-чистка на ВМ тоже есть).
|
||||
"""
|
||||
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 []
|
||||
if not refs:
|
||||
log.warning("upload_refs: no files, sid=%s", sid)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -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 };
|
||||
@@ -2,5 +2,14 @@
|
||||
"name": "upload-frontend",
|
||||
"private": true,
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,54 @@
|
||||
// addFileWithDedup — дедуп «имя+размер», суффиксы _2/_3, лимиты (over).
|
||||
// Мутирует state. Возвращает true если файл добавлен (или переведён в over),
|
||||
// false если это дедуп (обновлена только дата).
|
||||
|
||||
export function addFileWithDedup(state, file, cfg) {
|
||||
const size = file.size;
|
||||
const mtime = file.lastModified;
|
||||
let name = file.name;
|
||||
const maxFileBytes = cfg.maxFileBytes;
|
||||
const maxSessionBytes = cfg.maxSessionBytes;
|
||||
|
||||
if (state.fileMeta.has(name)) {
|
||||
const e = state.fileMeta.get(name);
|
||||
if (e.size === size) {
|
||||
// Тот же файл (имя+размер) — дедуп. Дату не сравниваем: файл могли пересохранить
|
||||
// с тем же содержимым. Оставляем более свежий по дате.
|
||||
if (mtime > e.mtime) {
|
||||
e.mtime = mtime;
|
||||
const idx = state.files.findIndex(f => f.name === name);
|
||||
if (idx >= 0) state.files[idx] = new File([file], name, { lastModified: mtime });
|
||||
/**
|
||||
* Добавляет узел файла или группы в состояние picker с рекурсивной дедупликацией.
|
||||
*
|
||||
* @param {{nodes: Array, fileKeys: Set<string>}} state Внутреннее состояние таблицы.
|
||||
* @param {{kind: string, path: string, file?: File, children?: Array}} fileNode
|
||||
* Корневой узел файла, папки или ZIP-архива.
|
||||
* @returns {boolean} True, если в состояние добавлен хотя бы один узел.
|
||||
*
|
||||
* Ключ дедупликации состоит из полного пути и размера файла. Это позволяет
|
||||
* повторно выбрать файл после удаления и одновременно не скрывает файл с тем
|
||||
* же путём, но другим размером.
|
||||
*/
|
||||
export function addFileWithDedup(state, fileNode) {
|
||||
// Удаляет дубли и пустые группы снизу вверх, сохраняя исходные File objects.
|
||||
const accept = (node) => {
|
||||
if (node.kind === 'file') {
|
||||
// NUL-разделитель исключает неоднозначность при склейке пути и размера.
|
||||
const key = `${node.path}\u0000${node.file.size}`;
|
||||
if (state.fileKeys.has(key)) return null;
|
||||
state.fileKeys.add(key);
|
||||
return node;
|
||||
}
|
||||
return false; // дедуп: файл не добавлен (обновлена только дата)
|
||||
}
|
||||
// Имя то же, размер другой — добавить с суффиксом _2, _3...
|
||||
const dot = name.lastIndexOf('.');
|
||||
const base = dot > 0 ? name.slice(0, dot) : name;
|
||||
const ext = dot > 0 ? name.slice(dot) : '';
|
||||
let n = 2;
|
||||
while (state.fileMeta.has(base + '_' + n + ext)) n++;
|
||||
name = base + '_' + n + ext;
|
||||
}
|
||||
state.fileMeta.set(name, { size, mtime });
|
||||
|
||||
// Определяем, превышает ли файл лимит (по размеру файла или суммарный) — не участвует в обфускации
|
||||
let over = false;
|
||||
if (size > maxFileBytes) over = true; // лимит на один файл
|
||||
const sum = state.files.reduce((s, f) => s + (state.overNames.has(f.name) ? 0 : f.size), 0);
|
||||
if (sum + size > maxSessionBytes) over = true; // суммарный лимит сессии
|
||||
|
||||
if (over) {
|
||||
state.overNames.add(name);
|
||||
state.files.push(new File([file], name, { lastModified: mtime }));
|
||||
// Группа нужна только пока после фильтрации в ней остался хотя бы один leaf.
|
||||
node.children = node.children.map(accept).filter(Boolean);
|
||||
return node.children.length ? node : null;
|
||||
};
|
||||
const accepted = accept(fileNode);
|
||||
// Пустой ZIP/каталог не должен появляться в таблице как пустая строка.
|
||||
if (!accepted || (accepted.kind !== 'file' && !accepted.children.length)) return false;
|
||||
if (accepted.kind !== 'file') {
|
||||
const existing = state.nodes.find((node) => node.kind !== 'file' && node.path === accepted.path);
|
||||
if (existing) {
|
||||
mergeChildren(existing, accepted);
|
||||
return true;
|
||||
}
|
||||
state.files.push(new File([file], name, { lastModified: mtime }));
|
||||
}
|
||||
state.nodes.push(accepted);
|
||||
return true;
|
||||
}
|
||||
|
||||
function mergeChildren(target, incoming) {
|
||||
incoming.children.forEach((child) => {
|
||||
if (child.kind === 'file') {
|
||||
const duplicate = target.children.some((existing) => existing.kind === 'file'
|
||||
&& existing.path === child.path && existing.file.size === child.file.size);
|
||||
if (!duplicate) target.children.push(child);
|
||||
return;
|
||||
}
|
||||
const existing = target.children.find((candidate) => candidate.kind !== 'file'
|
||||
&& candidate.path === child.path);
|
||||
if (existing) mergeChildren(existing, child);
|
||||
else target.children.push(child);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
// esc — экранирование HTML (защита от self-XSS именами файлов).
|
||||
|
||||
export function esc(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
/**
|
||||
* Экранирует текст перед вставкой в HTML-шаблон, собираемый через innerHTML.
|
||||
*
|
||||
* @param {*} value Имя файла, путь или сообщение статуса.
|
||||
* @returns {string} Строка с заменёнными HTML-значимыми символами.
|
||||
*
|
||||
* Экранируются и кавычки, потому что значение может попасть не только в текст
|
||||
* кнопки, но и в HTML-атрибут вроде data-path или aria-label.
|
||||
*/
|
||||
export function esc(value) {
|
||||
return String(value).replace(/[&<>"']/g, (character) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[character]));
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// estForFile — эмпирическая оценка времени обработки файла: сек/МБ (ориентировочно, до старта).
|
||||
|
||||
export const DEFAULT_EST_MB_SEC = 12;
|
||||
|
||||
export function estForFile(f, estMbSec) {
|
||||
const k = estMbSec || DEFAULT_EST_MB_SEC;
|
||||
return f ? Math.max(1, Math.round(f.size / 1048576 * k)) : 0;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// fmtSec — формат секунд: "Nс" / "Nм Nс".
|
||||
|
||||
export function fmtSec(s) {
|
||||
s = Math.max(0, Math.round(s));
|
||||
return s >= 60 ? Math.floor(s / 60) + 'м ' + (s % 60) + 'с' : s + 'с';
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
// fs — формат размера: B / KB / MB.
|
||||
|
||||
export function fs(b) {
|
||||
return b < 1024 ? b + ' B'
|
||||
: b < 1048576 ? (b / 1024).toFixed(1) + ' KB'
|
||||
: (b / 1048576).toFixed(1) + ' MB';
|
||||
/**
|
||||
* Преобразует размер файла из байтов в короткую строку для таблицы.
|
||||
*
|
||||
* @param {number} bytes Размер в байтах.
|
||||
* @returns {string} Значение в B, KB или MB с одним знаком после запятой.
|
||||
*/
|
||||
export function fs(bytes) {
|
||||
return bytes < 1024 ? `${bytes} B`
|
||||
: bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB`
|
||||
: `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
// initUploadTable — сборка слоя 1 (выбор файлов/папки/архива).
|
||||
// Состояние (files/fileMeta/overNames) живёт внутри модуля.
|
||||
|
||||
import { addFiles as addFilesToState, makeFilesChangeHandler } from './on_files_change.js';
|
||||
import { makeFolderChangeHandler } from './on_folder_change.js';
|
||||
import { render } from './render.js';
|
||||
import { setStatus } from './set_status.js';
|
||||
import { rmFile } from './rm_file.js';
|
||||
|
||||
// cfg: {allowedExt, maxFileBytes, maxSessionBytes, estMbSec}
|
||||
// els: {fileInput, folderInput, tableBody, countEl, uploadBtnEl, onStatus(cls, text)}
|
||||
// returns api (см. README.md)
|
||||
export function initUploadTable(cfg, els) {
|
||||
const state = {
|
||||
files: [], // File[]
|
||||
fileMeta: new Map(), // имя -> {size, mtime} для дедупа/суффиксов
|
||||
overNames: new Set(), // имена файлов сверх лимита (не участвуют)
|
||||
busy: false, // идёт загрузка/обработка — список заблокирован
|
||||
proc: null, // {phase, procState, procExtractRate} — задаёт слой 3
|
||||
};
|
||||
|
||||
const onFilesChange = makeFilesChangeHandler({ state, cfg, els, onStatus: els.onStatus });
|
||||
const onFolderChange = makeFolderChangeHandler({ state, cfg, els, onStatus: els.onStatus });
|
||||
|
||||
els.fileInput.addEventListener('change', onFilesChange);
|
||||
els.folderInput.addEventListener('change', onFolderChange);
|
||||
// Делегирование кликов по кнопкам «✕» (удалить строку)
|
||||
els.tableBody.addEventListener('click', e => {
|
||||
const btn = e.target.closest('.remove-btn');
|
||||
if (btn) rmFile(state, els, cfg, Number(btn.dataset.idx));
|
||||
});
|
||||
|
||||
return {
|
||||
state, // доступ для слоя 3 (установить state.proc для render)
|
||||
addFiles(files) { return addFilesToState(state, cfg, files, els, els.onStatus); },
|
||||
getFiles() { // ТОЛЬКО учитываемые (без over): [{name, size, file}]
|
||||
return state.files.filter(f => !state.overNames.has(f.name))
|
||||
.map(f => ({ name: f.name, size: f.size, file: f }));
|
||||
},
|
||||
getOverNames() { return state.overNames; },
|
||||
setStatus(idx, html) { setStatus(idx, html); },
|
||||
render() { render(state, els, cfg); },
|
||||
clear() {
|
||||
state.files = [];
|
||||
state.fileMeta = new Map();
|
||||
state.overNames = new Set();
|
||||
if (els.fileInput) els.fileInput.value = '';
|
||||
render(state, els, cfg);
|
||||
},
|
||||
setBusy(b) {
|
||||
state.busy = b;
|
||||
if (els.fileInput) els.fileInput.disabled = b;
|
||||
},
|
||||
_rm(i) { rmFile(state, els, cfg, i); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* lst_filter — фильтрация файлов каталога по manifest-файлу `obscure.lst`.
|
||||
*
|
||||
* Модуль намеренно чистый: не обращается к DOM, сети, upload или Flask.
|
||||
* На вход получает список File из folder picker и набор поддерживаемых
|
||||
* расширений (включая '.zip'), на выходе отдаёт отфильтрованный список File
|
||||
* в исходном порядке и диагностику для интерфейса.
|
||||
*
|
||||
* Формат manifest:
|
||||
* - одна строка — одно правило;
|
||||
* - пустые строки игнорируются;
|
||||
* - строки, начинающиеся с '#', — комментарии;
|
||||
* - '!' в начале строки задаёт исключение;
|
||||
* - правила задают пути относительно выбранной папки;
|
||||
* - положительные правила собирают кандидатов, исключения удаляют совпадения.
|
||||
*
|
||||
* Glob-семантика:
|
||||
* - '*' — ноль или больше символов в одном сегменте (не '/');
|
||||
* - '?' — один символ, кроме '/';
|
||||
* - '**' — целый сегмент: рекурсивное совпадение через каталоги;
|
||||
* внутри сегмента ('a**b') эквивалентен '*';
|
||||
* - '[abc]', '[a-z]' — классы и диапазоны символов;
|
||||
* - '[!a]', '[^a]' — отрицательные классы символов;
|
||||
* - 'reports/' эквивалентно 'reports/**';
|
||||
* - путь без glob-символов — точное совпадение;
|
||||
* - сравнение всего пути без учёта регистра.
|
||||
*/
|
||||
|
||||
/** Фиксированное имя manifest-файла (ищется только в корне выбранной папки). */
|
||||
const MANIFEST_NAME = 'obscure.lst';
|
||||
|
||||
/**
|
||||
* Возвращает true, если имя файла оканчивается одним из разрешённых расширений.
|
||||
*
|
||||
* Сравнение без учёта регистра: 'A.PDF' и '.pdf' считаются одинаковыми.
|
||||
*
|
||||
* @param {string} name Имя файла.
|
||||
* @param {string[]} allowedExt Список расширений (с точкой) включая '.zip'.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function extensionAllowed(name, allowedExt) {
|
||||
const lowerName = String(name || '').toLowerCase();
|
||||
return allowedExt.some((extension) => lowerName.endsWith(String(extension).toLowerCase()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Вычисляет путь файла относительно корня выбранной папки.
|
||||
*
|
||||
* Браузер для folder picker кладёт в `webkitRelativePath` путь вида
|
||||
* `<корень>/подпапка/файл`; первый сегмент (имя выбранной папки) отбрасываем.
|
||||
* Обратные слеши нормализуются в '/', чтобы правило можно было писать
|
||||
* в любом стиле.
|
||||
*
|
||||
* @param {{webkitRelativePath?: string, name: string}} file Файл или его фейк.
|
||||
* @returns {string} Путь относительно выбранной папки.
|
||||
*/
|
||||
function relativePathOf(file) {
|
||||
const raw = String(file.webkitRelativePath || file.name || '').replace(/\\/g, '/');
|
||||
const parts = raw.split('/');
|
||||
return parts.length > 1 ? parts.slice(1).join('/') : (parts[0] || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ищет ровно корневой `obscure.lst` (без учёта регистра).
|
||||
*
|
||||
* Manifest — это файл на глубине 1 от выбранной папки. Вложенный
|
||||
* `subdir/obscure.lst` манифестом не считается; другие `*.lst` тоже.
|
||||
*
|
||||
* @param {Array} files Список файлов folder picker.
|
||||
* @returns {object|null} Найденный manifest либо null.
|
||||
*/
|
||||
function findManifest(files) {
|
||||
return files.find((file) => {
|
||||
const raw = String(file.webkitRelativePath || file.name || '').replace(/\\/g, '/');
|
||||
const parts = raw.split('/');
|
||||
return parts.length === 2 && parts[1].toLowerCase() === MANIFEST_NAME;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Экранирует символы регулярного выражения, чтобы glob-литералы
|
||||
* не превращались в regex-метасимволы.
|
||||
*
|
||||
* @param {string} text Произвольная строка.
|
||||
* @returns {string} Строка, безопасная для вставки в RegExp.
|
||||
*/
|
||||
function escapeRegExp(text) {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Находит закрывающую ']' для класса символов.
|
||||
*
|
||||
* Учитывает, что ']' сразу после '[' или '[!' / '[^' является литералом,
|
||||
* а не закрывающей скобкой.
|
||||
*
|
||||
* @param {string} glob Исходный шаблон.
|
||||
* @param {number} openIndex Позиция открывающей '['.
|
||||
* @returns {number} Индекс закрывающей ']' либо -1, если класс не закрыт.
|
||||
*/
|
||||
function findClosingBracket(glob, openIndex) {
|
||||
let index = openIndex + 1;
|
||||
if (glob[index] === '!' || glob[index] === '^') index += 1;
|
||||
if (glob[index] === ']') index += 1;
|
||||
for (; index < glob.length; index += 1) {
|
||||
if (glob[index] === ']') return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Компилирует glob в регистронезависимый RegExp.
|
||||
*
|
||||
* @param {string} glob Нормализованный шаблон правила.
|
||||
* @returns {RegExp|null} null, если шаблон синтаксически некорректен
|
||||
* (например, незакрытый класс символов).
|
||||
*/
|
||||
function compileGlob(glob) {
|
||||
let pattern = '^';
|
||||
let index = 0;
|
||||
|
||||
while (index < glob.length) {
|
||||
const character = glob[index];
|
||||
|
||||
if (character === '*') {
|
||||
const isDouble = glob[index + 1] === '*';
|
||||
if (!isDouble) {
|
||||
// Одиночная звёздочка: любой набор символов внутри одного сегмента.
|
||||
pattern += '[^/]*';
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
// Двойная звёздочка рекурсивна только как целый сегмент пути.
|
||||
const atSegmentStart = index === 0 || glob[index - 1] === '/';
|
||||
const atSegmentEnd = index + 2 === glob.length || glob[index + 2] === '/';
|
||||
if (atSegmentStart && atSegmentEnd) {
|
||||
if (glob[index + 2] === '/') {
|
||||
// '**/' — ноль или больше сегментов вместе с завершающим '/'.
|
||||
pattern += '(?:[^/]+/)*';
|
||||
index += 3;
|
||||
} else {
|
||||
// '**' в конце правила — любой остаток пути.
|
||||
pattern += '.*';
|
||||
index += 2;
|
||||
}
|
||||
} else {
|
||||
// 'a**b' внутри сегмента эквивалентно 'a*b' и не пересекает '/'.
|
||||
pattern += '[^/]*';
|
||||
index += 2;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '?') {
|
||||
// Ровно один символ, но не разделитель каталогов.
|
||||
pattern += '[^/]';
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '[') {
|
||||
const close = findClosingBracket(glob, index);
|
||||
if (close === -1) return null;
|
||||
let body = glob.slice(index + 1, close);
|
||||
// '[!a]' — традиционная запись отрицательного класса, '[^a]' — regex-стиль.
|
||||
if (body.startsWith('!')) body = `^${body.slice(1)}`;
|
||||
pattern += `[${body.replace(/[\\\]]/g, (symbol) => `\\${symbol}`)}]`;
|
||||
index = close + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
pattern += escapeRegExp(character);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
pattern += '$';
|
||||
|
||||
try {
|
||||
return new RegExp(pattern, 'i');
|
||||
} catch (error) {
|
||||
// Некорректный класс (например, '[]') Regexp не компилирует — правило
|
||||
// отклоняется, остальные правила продолжают обрабатываться.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализует одну строку manifest в путь правила.
|
||||
*
|
||||
* @param {string} line Исходная строка.
|
||||
* @returns {null|{error: string}|{path: string, negative: boolean}}
|
||||
* null — пустая строка или комментарий;
|
||||
* {error} — некорректное правило;
|
||||
* {path, negative} — нормализованное правило.
|
||||
*/
|
||||
function normalizeRule(line) {
|
||||
let text = line.trim();
|
||||
|
||||
// Пустые строки и комментарии не являются правилами.
|
||||
if (!text || text.startsWith('#')) return null;
|
||||
|
||||
let negative = false;
|
||||
if (text.startsWith('!')) {
|
||||
negative = true;
|
||||
text = text.slice(1).trim();
|
||||
// Голое '!' — ошибка с указанием номера строки.
|
||||
if (!text) return { error: 'пустое правило после "!"' };
|
||||
}
|
||||
|
||||
// Единый разделитель, схлопывание повторных '/' и устранение ведущих './'.
|
||||
// Схлопывание идёт до снятия './', чтобы './/reports' превращалось в
|
||||
// 'reports', а не в абсолютный '/reports'.
|
||||
text = text.replace(/\\/g, '/');
|
||||
text = text.replace(/\/{2,}/g, '/');
|
||||
text = text.replace(/^(?:\.\/)+/, '');
|
||||
|
||||
// Абсолютные пути (POSIX и Windows drive) недопустимы.
|
||||
if (text.startsWith('/') || /^[a-zA-Z]:\//.test(text)) {
|
||||
return { error: 'абсолютный путь не допускается' };
|
||||
}
|
||||
|
||||
// Выход за пределы выбранной папки запрещён.
|
||||
if (text.split('/').some((segment) => segment === '..')) {
|
||||
return { error: 'сегмент ".." не допускается' };
|
||||
}
|
||||
|
||||
if (!text) return { error: 'пустое правило' };
|
||||
|
||||
// Правило 'reports/' означает всё поддерево reports.
|
||||
if (text.endsWith('/')) text += '**';
|
||||
|
||||
return { path: text, negative };
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбирает текст manifest в массив правил.
|
||||
*
|
||||
* Некорректные строки не прерывают разбор: они попадают в diagnostics
|
||||
* с номером строки, остальные правила продолжают работать.
|
||||
*
|
||||
* @param {string} text Содержимое manifest (BOM уже снят).
|
||||
* @param {Array} diagnostics Аккумулятор диагностики.
|
||||
* @returns {Array<{line: number, source: string, negative: boolean, regex: RegExp}>}
|
||||
*/
|
||||
function parseRules(text, diagnostics) {
|
||||
// Поддерживаем CRLF, LF и одиночный CR.
|
||||
const lines = text.split(/\r\n|\r|\n/);
|
||||
const rules = [];
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const lineNumber = index + 1;
|
||||
const normalized = normalizeRule(line);
|
||||
if (normalized === null) return;
|
||||
|
||||
if (normalized.error) {
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
line: lineNumber,
|
||||
message: `${normalized.error}: "${line.trim()}"`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const regex = compileGlob(normalized.path);
|
||||
if (!regex) {
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
line: lineNumber,
|
||||
message: `некорректный glob: "${line.trim()}"`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
rules.push({
|
||||
line: lineNumber,
|
||||
source: line.trim(),
|
||||
negative: normalized.negative,
|
||||
regex,
|
||||
});
|
||||
});
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Фильтрует файлы каталога по manifest-файлу `obscure.lst`.
|
||||
*
|
||||
* Поведение:
|
||||
* - нет корневого manifest — возвращаются все файлы (прежнее поведение);
|
||||
* - manifest есть — результат строится только по его правилам, fallback нет;
|
||||
* - сам manifest никогда не попадает в результат;
|
||||
* - файлы неподдерживаемых типов не попадают в кандидаты;
|
||||
* - ZIP фильтруется по имени архива, внутренние имена не анализируются.
|
||||
*
|
||||
* @param {FileList|File[]} files Файлы, выбранные через folder picker.
|
||||
* @param {string[]} allowedExt Поддерживаемые расширения, включая '.zip'.
|
||||
* @returns {Promise<{
|
||||
* manifest: object|null,
|
||||
* files: Array,
|
||||
* diagnostics: Array<{level: string, line: number, message: string}>
|
||||
* }>}
|
||||
*/
|
||||
export async function lstFilter(files, allowedExt) {
|
||||
const list = Array.from(files || []);
|
||||
const manifest = findManifest(list);
|
||||
|
||||
// Manifest отсутствует: сохраняем прежнее поведение без какой-либо фильтрации.
|
||||
if (!manifest) {
|
||||
return { manifest: null, files: list, diagnostics: [] };
|
||||
}
|
||||
|
||||
const diagnostics = [];
|
||||
|
||||
// Ошибка чтения или UTF-8-декодирования: предупреждение и пустой результат.
|
||||
// TextDecoder с fatal:true не подменяет битые байты на U+FFFD молча.
|
||||
let text;
|
||||
try {
|
||||
const buffer = await manifest.arrayBuffer();
|
||||
text = new TextDecoder('utf-8', { fatal: true }).decode(buffer);
|
||||
} catch (error) {
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
line: 0,
|
||||
message: `не удалось прочитать ${MANIFEST_NAME}: ${error.message}`,
|
||||
});
|
||||
return { manifest, files: [], diagnostics };
|
||||
}
|
||||
|
||||
// BOM в начале снимаем вручную, иначе он попадёт в первое правило.
|
||||
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
|
||||
|
||||
const rules = parseRules(text, diagnostics);
|
||||
const positives = rules.filter((rule) => !rule.negative);
|
||||
const negatives = rules.filter((rule) => rule.negative);
|
||||
|
||||
// Нет ни одного корректного включающего правила — пустой результат без fallback.
|
||||
if (positives.length === 0) {
|
||||
diagnostics.push({
|
||||
level: 'warning',
|
||||
line: 0,
|
||||
message: `в ${MANIFEST_NAME} нет корректных включающих правил`,
|
||||
});
|
||||
return { manifest, files: [], diagnostics };
|
||||
}
|
||||
|
||||
// Кандидаты: только поддерживаемые типы, без самого manifest.
|
||||
// ZIP остаётся обычным кандидатом — include/exclude применяются к его имени.
|
||||
const candidates = list.filter((file) => file !== manifest
|
||||
&& extensionAllowed(file.name, allowedExt));
|
||||
|
||||
// Проход по файлам (а не по правилам) сохраняет исходный порядок и
|
||||
// исключает появление дублей при пересекающихся масках.
|
||||
const matched = new Set();
|
||||
const selected = [];
|
||||
|
||||
for (const file of candidates) {
|
||||
const path = relativePathOf(file);
|
||||
|
||||
let included = false;
|
||||
for (const rule of positives) {
|
||||
if (rule.regex.test(path)) {
|
||||
matched.add(rule);
|
||||
included = true;
|
||||
}
|
||||
}
|
||||
if (!included) continue;
|
||||
|
||||
// Исключение применяется после положительного набора.
|
||||
const excluded = negatives.some((rule) => rule.regex.test(path));
|
||||
if (!excluded) selected.push(file);
|
||||
}
|
||||
|
||||
// Правило, не совпавшее ни с одним поддерживаемым файлом, — предупреждение,
|
||||
// остальные правила продолжают работать.
|
||||
for (const rule of positives) {
|
||||
if (!matched.has(rule)) {
|
||||
diagnostics.push({
|
||||
level: 'warning',
|
||||
line: rule.line,
|
||||
message: `правило не совпало ни с одним файлом: "${rule.source}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.length === 0) {
|
||||
diagnostics.push({
|
||||
level: 'warning',
|
||||
line: 0,
|
||||
message: 'manifest не выбрал ни одного файла',
|
||||
});
|
||||
}
|
||||
|
||||
return { manifest, files: selected, diagnostics };
|
||||
}
|
||||
@@ -1,56 +1,75 @@
|
||||
// Обработчик <input type="file" multiple> change: дедуп + раскрытие ZIP + фильтр.
|
||||
// Экспортируется и чистая логика добавления массива файлов (addFiles),
|
||||
// и фабрика обработчика для input (makeFilesChangeHandler).
|
||||
|
||||
import { listZipFiles } from '../zip/list_zip_files.js';
|
||||
import { addFileWithDedup } from './add_file_with_dedup.js';
|
||||
import { render } from './render.js';
|
||||
|
||||
// Добавить массив файлов в список (дедуп + раскрытие ZIP + лимиты).
|
||||
// files: File[]. els.fileInput — для синхронизации input.files (DataTransfer),
|
||||
// чтобы повторный выбор того же файла сработал. onStatus(cls, text) — колбэк сообщений.
|
||||
export async function addFiles(state, cfg, files, els, onStatus) {
|
||||
const incoming = Array.from(files);
|
||||
const hasZip = incoming.some(f => f.name.toLowerCase().endsWith('.zip'));
|
||||
if (hasZip) {
|
||||
document.body.style.cursor = 'wait';
|
||||
if (onStatus) onStatus('progress', 'Разбираю архивы…');
|
||||
/**
|
||||
* Обрабатывает список файлов из обычного file input.
|
||||
*
|
||||
* @param {{fileKeys: Set<string>, nodes: Array, busy: boolean}} state Состояние picker.
|
||||
* @param {{allowedExt: string[]}} cfg Конфигурация разрешённых расширений.
|
||||
* @param {FileList|File[]} files Выбранные browser File objects.
|
||||
* @param {{tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-элементы вывода.
|
||||
* @returns {Promise<void>} Завершается после разбора всех ZIP и перерисовки таблицы.
|
||||
*
|
||||
* Обычные документы добавляются сразу. ZIP читаются асинхронно в браузере;
|
||||
* ошибка одного архива не отменяет обработку остальных выбранных файлов.
|
||||
*/
|
||||
export async function addFiles(state, cfg, files, elements) {
|
||||
for (const file of Array.from(files)) {
|
||||
if (!file.name.toLowerCase().endsWith('.zip')) {
|
||||
// Фильтр повторяет accept в HTML, потому что accept не является защитой API.
|
||||
if (!cfg.allowedExt.some((extension) => file.name.toLowerCase().endsWith(extension.toLowerCase()))) {
|
||||
continue;
|
||||
}
|
||||
// Обычный файл получает имя как путь: у file input нет относительного пути.
|
||||
addFileWithDedup(state, {
|
||||
id: crypto.randomUUID(), kind: 'file', name: file.name, path: file.name,
|
||||
file, children: [], expanded: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
for (const f of incoming) {
|
||||
if (f.name.toLowerCase().endsWith('.zip')) {
|
||||
try {
|
||||
const nested = await listZipFiles(f, cfg.allowedExt);
|
||||
if (nested.length) nested.forEach(x => addFileWithDedup(state, x, cfg));
|
||||
else addFileWithDedup(state, f, cfg); // в архиве нет документов — архив как есть
|
||||
} catch (err) {
|
||||
addFileWithDedup(state, f, cfg); // не удалось распаковать — zip как есть
|
||||
// listZipFiles возвращает дерево только с разрешёнными leaf-файлами.
|
||||
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
||||
if (zipTree) addFileWithDedup(state, zipTree);
|
||||
} catch (error) {
|
||||
// Битый или небезопасный ZIP пропускается, чтобы не блокировать picker.
|
||||
if (typeof cfg.onError === 'function') {
|
||||
cfg.onError(error, file);
|
||||
} else if (elements.statusEl) {
|
||||
elements.statusEl.textContent = `Ошибка чтения архива ${file.name}: ${error.message}`;
|
||||
}
|
||||
} else {
|
||||
addFileWithDedup(state, f, cfg);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (hasZip) {
|
||||
document.body.style.cursor = '';
|
||||
if (onStatus) onStatus('', '');
|
||||
}
|
||||
}
|
||||
// Синхронизировать input.files — чтобы повторный выбор того же файла сработал
|
||||
if (els && els.fileInput && els.fileInput.files) {
|
||||
const d = new DataTransfer();
|
||||
state.files.forEach(f => d.items.add(f));
|
||||
els.fileInput.files = d.files;
|
||||
}
|
||||
render(state, els, cfg);
|
||||
// Единая перерисовка после всей пачки предотвращает промежуточные состояния UI.
|
||||
render(state, elements, cfg);
|
||||
}
|
||||
|
||||
// Фабрика обработчика change для <input type="file">.
|
||||
// ctx = { state, cfg, els, onStatus }
|
||||
export function makeFilesChangeHandler(ctx) {
|
||||
const { state, cfg, els, onStatus } = ctx;
|
||||
return () => {
|
||||
if (state.busy) return; // во время загрузки/обработки менять список нельзя
|
||||
addFiles(state, cfg, els.fileInput.files, els, onStatus);
|
||||
/**
|
||||
* Создаёт обработчик change для обычного выбора файлов.
|
||||
*
|
||||
* @param {object} state Состояние picker, включая флаг busy.
|
||||
* @param {object} cfg Конфигурация picker.
|
||||
* @param {object} elements DOM-элементы picker.
|
||||
* @returns {Function} Async-обработчик для addEventListener('change', ...).
|
||||
*
|
||||
* busy устанавливается синхронно до первого await. Поэтому второе быстрое
|
||||
* событие выбора не запускает параллельный разбор и не меняет дерево конкурирующе.
|
||||
*/
|
||||
export function onFilesChange(state, cfg, elements) {
|
||||
return async () => {
|
||||
if (state.busy) return;
|
||||
state.busy = true;
|
||||
// Сброс статуса перед новым выбором: иначе сообщение об ошибке предыдущего
|
||||
// выбора (например, «Ошибка чтения архива») останется висеть в интерфейсе.
|
||||
if (elements.statusEl) elements.statusEl.textContent = '';
|
||||
try {
|
||||
await addFiles(state, cfg, elements.fileInputEl.files, elements);
|
||||
} finally {
|
||||
// Даже исключение вне внутреннего ZIP-catch не оставляет picker заблокированным.
|
||||
elements.fileInputEl.value = '';
|
||||
state.busy = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,58 +1,107 @@
|
||||
// Обработчик <input webkitdirectory> change: выбор целой папки, рекурсивно,
|
||||
// относительный путь сохраняется (верхняя папка отбрасывается).
|
||||
|
||||
import { listZipFiles } from '../zip/list_zip_files.js';
|
||||
import { addFileWithDedup } from './add_file_with_dedup.js';
|
||||
import { rebaseTree } from './rebase_tree.js';
|
||||
import { render } from './render.js';
|
||||
import { lstFilter } from './lst_filter.js';
|
||||
|
||||
// Фабрика обработчика change для input выбора папки.
|
||||
// ctx = { state, cfg, els, onStatus }
|
||||
export function makeFolderChangeHandler(ctx) {
|
||||
const { state, cfg, els, onStatus } = ctx;
|
||||
/**
|
||||
* Создаёт async-обработчик выбора каталога через webkitdirectory.
|
||||
*
|
||||
* @param {{nodes: Array, fileKeys: Set<string>, busy: boolean}} state Состояние picker.
|
||||
* @param {{allowedExt: string[]}} cfg Разрешённые расширения.
|
||||
* @param {{folderInputEl: HTMLInputElement, tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-элементы.
|
||||
* @returns {Function} Async-обработчик события change.
|
||||
*
|
||||
* Браузер отдаёт плоский FileList с webkitRelativePath. Обработчик группирует
|
||||
* его по первой части пути, создаёт промежуточные папки и затем добавляет каждый
|
||||
* корень через общую дедупликацию. ZIP внутри каталога раскрывается тем же кодом,
|
||||
* что и ZIP из обычного file input.
|
||||
*/
|
||||
export function onFolderChange(state, cfg, elements) {
|
||||
return async () => {
|
||||
if (state.busy) return; // во время загрузки/обработки менять список нельзя
|
||||
const incoming = Array.from(els.folderInput.files);
|
||||
if (!incoming.length) return;
|
||||
document.body.style.cursor = 'wait';
|
||||
if (onStatus) onStatus('progress', 'Разбираю папку…');
|
||||
let added = 0;
|
||||
if (state.busy) return;
|
||||
state.busy = true;
|
||||
// Сброс статуса перед новым выбором: иначе сообщение об ошибке предыдущего
|
||||
// выбора (например, «Ошибка чтения архива») останется висеть в интерфейсе.
|
||||
if (elements.statusEl) elements.statusEl.textContent = '';
|
||||
try {
|
||||
for (const f of incoming) {
|
||||
// webkitRelativePath: "TopFolder/Подпапка/file.pdf" — отбрасываем верхнюю папку
|
||||
const parts = (f.webkitRelativePath || f.name).split('/');
|
||||
const rel = parts.slice(1).join('/') || f.name;
|
||||
const low = rel.toLowerCase();
|
||||
const slashIdx = rel.lastIndexOf('/');
|
||||
const relDir = slashIdx >= 0 ? rel.slice(0, slashIdx) : '';
|
||||
if (low.endsWith('.zip')) {
|
||||
const roots = new Map();
|
||||
// Manifest-фильтр: при наличии корневого `obscure.lst` набор файлов
|
||||
// ограничивается его правилами, а сам manifest в дерево не попадает.
|
||||
// Без manifest модуль возвращает исходный список без изменений.
|
||||
const filtered = await lstFilter(elements.folderInputEl.files, [...cfg.allowedExt, '.zip']);
|
||||
if (filtered.manifest) {
|
||||
// Диагностику показываем в statusEl: ошибки строк и предупреждения правил.
|
||||
const messages = filtered.diagnostics.map((item) => (
|
||||
item.line ? `строка ${item.line}: ${item.message}` : item.message
|
||||
));
|
||||
if (messages.length && elements.statusEl) {
|
||||
elements.statusEl.textContent = messages.join('; ');
|
||||
}
|
||||
}
|
||||
for (const file of filtered.files) {
|
||||
// Для webkitdirectory путь начинается с выбранной корневой папки.
|
||||
const parts = (file.webkitRelativePath || file.name).split('/');
|
||||
const relativePath = parts.slice(1).join('/') || file.name;
|
||||
const lowerPath = relativePath.toLowerCase();
|
||||
const rootName = parts[0] || file.name;
|
||||
if (!roots.has(rootName)) {
|
||||
// Один root на выбранный каталог позволяет сохранить дерево целиком.
|
||||
roots.set(rootName, {
|
||||
id: crypto.randomUUID(), kind: 'folder', name: rootName,
|
||||
path: rootName, children: [], expanded: false
|
||||
});
|
||||
}
|
||||
const root = roots.get(rootName);
|
||||
// Вставляет узел по его пути, создавая отсутствующие промежуточные папки.
|
||||
const addToFolder = (node) => {
|
||||
let current = root;
|
||||
const nodeParts = node.path.split('/').slice(1);
|
||||
nodeParts.forEach((part, index) => {
|
||||
const last = index === nodeParts.length - 1;
|
||||
let child = current.children.find((item) => item.name === part);
|
||||
if (!child) {
|
||||
child = last ? node : {
|
||||
id: crypto.randomUUID(), kind: 'folder', name: part,
|
||||
path: `${rootName}/${nodeParts.slice(0, index + 1).join('/')}`,
|
||||
children: [], expanded: false
|
||||
};
|
||||
current.children.push(child);
|
||||
}
|
||||
current = child;
|
||||
});
|
||||
};
|
||||
if (lowerPath.endsWith('.zip')) {
|
||||
try {
|
||||
const nested = await listZipFiles(f, cfg.allowedExt);
|
||||
if (nested.length) {
|
||||
for (const nf of nested) {
|
||||
const nm = relDir ? relDir + '/' + nf.name : nf.name;
|
||||
if (addFileWithDedup(state, new File([nf], nm, { lastModified: nf.lastModified }), cfg)) added++;
|
||||
// ZIP rebased на имя выбранной папки перед вставкой в общий root.
|
||||
const zip = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
||||
if (zip) {
|
||||
rebaseTree(zip, rootName);
|
||||
addToFolder(zip);
|
||||
}
|
||||
} else {
|
||||
// в архиве нет документов — добавить архив как есть, чтобы не терялся
|
||||
if (addFileWithDedup(state, new File([f], rel, { lastModified: f.lastModified }), cfg)) added++;
|
||||
} catch (error) {
|
||||
// Ошибка одного ZIP не должна терять остальные файлы каталога.
|
||||
if (typeof cfg.onError === 'function') {
|
||||
cfg.onError(error, file);
|
||||
} else if (elements.statusEl) {
|
||||
elements.statusEl.textContent = `Ошибка чтения архива ${file.name}: ${error.message}`;
|
||||
}
|
||||
} catch (err) {
|
||||
if (addFileWithDedup(state, new File([f], rel, { lastModified: f.lastModified }), cfg)) added++; // zip как есть
|
||||
continue;
|
||||
}
|
||||
} else if (cfg.allowedExt.some(e => low.endsWith(e))) {
|
||||
if (addFileWithDedup(state, new File([f], rel, { lastModified: f.lastModified }), cfg)) added++;
|
||||
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
||||
addToFolder({
|
||||
id: crypto.randomUUID(), kind: 'file', name: parts.at(-1),
|
||||
path: `${rootName}/${relativePath}`, file, children: [], expanded: true
|
||||
});
|
||||
}
|
||||
// иначе — не документ, пропускаем
|
||||
}
|
||||
// Дедупликация выполняется после сборки каждого корня каталога.
|
||||
roots.forEach((root) => addFileWithDedup(state, root));
|
||||
// Сброс value позволяет выбрать тот же каталог повторно.
|
||||
elements.folderInputEl.value = '';
|
||||
render(state, elements, cfg);
|
||||
} finally {
|
||||
document.body.style.cursor = '';
|
||||
}
|
||||
els.folderInput.value = ''; // чтобы повторный выбор той же папки сработал
|
||||
render(state, els, cfg);
|
||||
if (added && onStatus) {
|
||||
const n = added;
|
||||
const w = (n % 10 === 1 && n % 100 !== 11) ? 'файл' : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 'файла' : 'файлов';
|
||||
onStatus('done', '✅ Добавлено из папки: ' + n + ' ' + w);
|
||||
state.busy = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// procRow — строка таблицы обработки (3-секционная).
|
||||
|
||||
import { esc } from './esc.js';
|
||||
import { fs } from './fs.js';
|
||||
|
||||
export function procRow(state, i, stTxt) {
|
||||
const f = state.files[i];
|
||||
const over = state.overNames.has(f.name);
|
||||
const p = state.proc && state.proc.procState ? state.proc.procState[i] : null;
|
||||
const cls = (p && p.st === 'current') ? ' class="row-current"'
|
||||
: (over ? ' class="row-over"' : '');
|
||||
return '<tr' + cls + '><td class="name-cell">' + esc(f.name) + '</td><td class="num-cell">' + fs(f.size) + '</td><td class="num-cell" style="font-size:12px;">' + stTxt + '</td><td></td></tr>';
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Добавляет префикс корневой папки к каждому пути дерева.
|
||||
*
|
||||
* @param {{path: string, file?: File, children: Array}} root Узел дерева.
|
||||
* @param {string} prefix Путь выбранной папки, в которую попал узел.
|
||||
* @returns {object} Тот же узел после изменения путей.
|
||||
*
|
||||
* Для leaf-файлов создаётся новый File с тем же базовым именем; логический путь
|
||||
* хранится отдельно в узле дерева.
|
||||
*/
|
||||
export function rebaseTree(root, prefix) {
|
||||
root.path = `${prefix}/${root.path}`;
|
||||
if (root.file) root.file = new File([root.file], root.file.name, { lastModified: root.file.lastModified });
|
||||
root.children.forEach((child) => rebaseTree(child, prefix));
|
||||
return root;
|
||||
}
|
||||
+151
-23
@@ -1,29 +1,157 @@
|
||||
// render — рендер таблицы выбора файлов (обычная).
|
||||
// Если идёт обработка (state.proc.phase === 'processing') — 3-секционная.
|
||||
|
||||
import { esc } from './esc.js';
|
||||
import { fs } from './fs.js';
|
||||
import { fmtSec } from './fmt_sec.js';
|
||||
import { estForFile } from './est_for_file.js';
|
||||
import { renderProcTable } from './render_proc_table.js';
|
||||
|
||||
export function render(state, els, cfg) {
|
||||
if (state.proc && state.proc.phase === 'processing') { renderProcTable(state, els, cfg); return; }
|
||||
if (state.files.length === 0) {
|
||||
els.tableBody.innerHTML = '<tr class="empty-row"><td colspan="4">Нет выбранных файлов</td></tr>';
|
||||
const DEFAULT_RENDER_CONFIG = {
|
||||
labels: {
|
||||
statusReady: 'готов',
|
||||
remove: 'Удалить',
|
||||
empty: 'Нет выбранных файлов',
|
||||
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
|
||||
count: (count, bytes) => `${count} файлов · ${fs(bytes)}`,
|
||||
},
|
||||
layout: { columns: ['path', 'size', 'status'] },
|
||||
};
|
||||
|
||||
function renderConfig(cfg = {}) {
|
||||
return {
|
||||
labels: {
|
||||
...DEFAULT_RENDER_CONFIG.labels,
|
||||
...(cfg.labels || {}),
|
||||
columns: {
|
||||
...DEFAULT_RENDER_CONFIG.labels.columns,
|
||||
...(cfg.labels?.columns || {}),
|
||||
},
|
||||
},
|
||||
layout: { ...DEFAULT_RENDER_CONFIG.layout, ...(cfg.layout || {}) },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит один узел дерева и его раскрытых потомков в HTML-строки таблицы.
|
||||
*
|
||||
* @param {{kind: string, id: string, name: string, path: string, file?: File,
|
||||
* children: Array, expanded: boolean, error?: string}} node Узел file/folder/zip.
|
||||
* @param {number} depth Глубина узла; используется для визуального отступа.
|
||||
* @returns {string} HTML одной строки или поддерева строк.
|
||||
*
|
||||
* Имена и пути проходят через esc до вставки в innerHTML. Группа получает
|
||||
* кнопку раскрытия, leaf-файл — размер, статус и data-path для будущего API статусов.
|
||||
*/
|
||||
function leafStatuses(node, state, result = []) {
|
||||
if (node.kind === 'file') {
|
||||
result.push(state.statuses.get(node.id) || { kind: 'ready', text: '' });
|
||||
} else {
|
||||
els.tableBody.innerHTML = state.files.map((f, i) => {
|
||||
const over = state.overNames.has(f.name);
|
||||
const rowCls = over ? ' class="row-over"' : '';
|
||||
const stTxt = over ? '<span style="color:#c0392b;">🔥 не учитывается</span>'
|
||||
: '<span style="color:#7d3c98;">~' + fmtSec(estForFile(f, cfg.estMbSec)) + '</span>';
|
||||
return '<tr id="row-' + i + '"' + rowCls + '><td class="name-cell">' + esc(f.name) + '</td><td class="num-cell">' + fs(f.size) + '</td><td class="num-cell" id="st-' + i + '" style="font-size:12px;">' + stTxt + '</td><td><button class="remove-btn" data-idx="' + i + '">✕</button></td></tr>';
|
||||
}).join('');
|
||||
node.children.forEach((child) => leafStatuses(child, state, result));
|
||||
}
|
||||
const overCount = state.files.filter(f => state.overNames.has(f.name)).length;
|
||||
const totalSize = state.files.reduce((s, f) => s + (state.overNames.has(f.name) ? 0 : f.size), 0);
|
||||
const cntMain = state.files.length - overCount;
|
||||
const totEst = state.files.reduce((s, f) => s + (state.overNames.has(f.name) ? 0 : estForFile(f, cfg.estMbSec)), 0);
|
||||
els.countEl.textContent = (overCount ? cntMain + ' учитываются + ' + overCount + ' свыше лимита' : state.files.length) + ' файлов · ' + fs(totalSize) + ' · ~' + fmtSec(totEst);
|
||||
els.uploadBtnEl.disabled = cntMain === 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
function renderStatus(node, state, cfg) {
|
||||
if (node.kind === 'file') {
|
||||
const status = state.statuses.get(node.id) || { kind: 'ready', text: cfg.labels.statusReady };
|
||||
const kind = ['uploading', 'parsing', 'parsed', 'duplicate', 'error'].includes(status.kind)
|
||||
? status.kind
|
||||
: 'ready';
|
||||
return `<span class="fp-status-${kind}">${esc(status.text || cfg.labels.statusReady)}</span>`;
|
||||
}
|
||||
const statuses = leafStatuses(node, state);
|
||||
const errors = statuses.filter((status) => status.kind === 'error').length;
|
||||
const uploading = statuses.filter((status) => status.kind === 'uploading' || status.kind === 'parsing').length;
|
||||
const parsed = statuses.filter((status) => ['parsed', 'duplicate'].includes(status.kind)).length;
|
||||
if (uploading) return `<span class="fp-status-uploading">${uploading} загружается/обрабатывается</span>`;
|
||||
if (errors) return `<span class="fp-status-error">${errors} ошибок</span>`;
|
||||
if (parsed === statuses.length) return `<span class="fp-status-parsed">${statuses.length} готово</span>`;
|
||||
return `${parsed}/${statuses.length} обработано`;
|
||||
}
|
||||
|
||||
function renderNode(node, depth, cfg, state) {
|
||||
const isGroup = node.kind !== 'file';
|
||||
const padding = depth * 4;
|
||||
const name = esc(node.name);
|
||||
const action = isGroup
|
||||
? `<button class="tree-name tree-group tree-${node.kind}" style="padding-left:${padding}ch" data-toggle="${node.id}" `
|
||||
+ `aria-expanded="${node.expanded}"><span class="tree-chevron">${node.expanded ? '▼' : '▶'}</span>${name}</button>`
|
||||
: `<span class="tree-name" style="padding-left:${padding}ch">${name}</span>`;
|
||||
const documentId = state.documents.get(node.id);
|
||||
const details = node.kind === 'file' && documentId
|
||||
? `<button class="fp-action" type="button" data-details="${esc(node.id)}" aria-label="${esc(cfg.labels.details)} ${name}" title="${esc(cfg.labels.details)}">Детали</button>`
|
||||
+ `<button class="fp-action" type="button" data-text="${esc(node.id)}" aria-label="${esc(cfg.labels.text)} ${name}" title="${esc(cfg.labels.text)}">Текст</button>`
|
||||
: '';
|
||||
const remove = `<button class="remove-btn" type="button" data-remove="${esc(node.id)}" aria-label="${esc(cfg.labels.remove)} ${name}">×</button>`;
|
||||
const pathAttribute = ` data-path="${esc(node.path)}"`;
|
||||
const fileIdAttribute = node.kind === 'file' ? ` data-file-id="${esc(node.id)}"` : '';
|
||||
const cells = {
|
||||
path: action,
|
||||
size: node.kind === 'file' ? fs(node.file.size) : '',
|
||||
status: node.error ? esc(node.error) : renderStatus(node, state, cfg),
|
||||
};
|
||||
const columns = cfg.layout.columns?.length
|
||||
? cfg.layout.columns
|
||||
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
|
||||
const row = `<tr class="tree-row tree-${node.kind}"${pathAttribute}${fileIdAttribute}>`
|
||||
+ columns.map((column) => `<td>${cells[column] || ''}</td>`).join('')
|
||||
+ `<td class="fp-actions">${details}${remove}</td></tr>`;
|
||||
if (!isGroup || !node.expanded) return row;
|
||||
// Дочерние строки добавляются только для раскрытой группы.
|
||||
return row + node.children.map((child) => renderNode(child, depth + 1, cfg, state)).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Полностью синхронизирует DOM таблицы и счётчик с текущим state.nodes.
|
||||
*
|
||||
* @param {{nodes: Array}} state Состояние дерева.
|
||||
* @param {{tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-вывод.
|
||||
* @returns {void}
|
||||
*
|
||||
* Обход для счётчика независим от раскрытия: свернутая группа всё равно входит
|
||||
* в количество leaf-файлов и суммарный размер.
|
||||
*/
|
||||
export function render(state, elements, cfg) {
|
||||
const normalized = renderConfig(cfg);
|
||||
const columns = normalized.layout.columns?.length
|
||||
? normalized.layout.columns
|
||||
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
|
||||
elements.tableBodyEl.innerHTML = state.nodes.length
|
||||
? state.nodes.map((node) => renderNode(node, 0, normalized, state)).join('')
|
||||
: `<tr><td colspan="${columns.length + 1}">${esc(normalized.labels.empty)}</td></tr>`;
|
||||
const files = [];
|
||||
// Собирает все leaf-файлы, включая содержимое свернутых групп.
|
||||
const visit = (node) => {
|
||||
if (node.kind === 'file') files.push(node);
|
||||
else node.children.forEach(visit);
|
||||
};
|
||||
state.nodes.forEach(visit);
|
||||
const totalBytes = files.reduce((sum, node) => sum + node.file.size, 0);
|
||||
elements.countEl.textContent = normalized.labels.count(files.length, totalBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ищет узел по уникальному id и возвращает также массив его непосредственного родителя.
|
||||
*
|
||||
* @param {Array} nodes Массив узлов текущего уровня.
|
||||
* @param {string} id Идентификатор, созданный через crypto.randomUUID().
|
||||
* @returns {{node: object, nodes: Array}|null} Узел и контейнер для удаления либо null.
|
||||
*/
|
||||
export function findNode(nodes, id) {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return { node, nodes };
|
||||
const found = findNode(node.children || [], id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Преобразует иерархическое дерево в плоский список leaf-файлов.
|
||||
*
|
||||
* @param {Array} nodes Узлы любого уровня.
|
||||
* @param {Array} result Внутренний аккумулятор рекурсивного вызова.
|
||||
* @returns {Array<{id: string, path: string, name: string, size: number, file: File}>} Файлы для приложения.
|
||||
*/
|
||||
export function flattenFiles(nodes, result = []) {
|
||||
nodes.forEach((node) => {
|
||||
if (node.kind === 'file') result.push({ id: node.id, path: node.path, name: node.name, size: node.file.size, file: node.file });
|
||||
else flattenFiles(node.children, result);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
// renderProcTable — 3-секционная таблица во время обработки
|
||||
// (готово / текущий / ожидают / пропущены сверх лимита).
|
||||
|
||||
import { fmtSec } from './fmt_sec.js';
|
||||
import { estForFile, DEFAULT_EST_MB_SEC } from './est_for_file.js';
|
||||
import { procRow } from './proc_row.js';
|
||||
|
||||
export function renderProcTable(state, els, cfg) {
|
||||
const procState = state.proc.procState;
|
||||
const groups = { done: [], current: [], pending: [], over: [] };
|
||||
for (let i = 0; i < state.files.length; i++) {
|
||||
if (state.overNames.has(state.files[i].name)) { groups.over.push(i); continue; }
|
||||
const st = procState[i] ? procState[i].st : 'pending';
|
||||
if (st === 'done' || st === 'skipped') groups.done.push(i);
|
||||
else if (st === 'current') groups.current.push(i);
|
||||
else groups.pending.push(i);
|
||||
}
|
||||
// Оценка скорости из текущего файла (сек/символ) — для «ожидающих»
|
||||
let rate = null;
|
||||
for (const i of groups.current) {
|
||||
const p = procState[i];
|
||||
const cur = (performance.now() - p.t0) / 1000;
|
||||
const total = cur + (p.eta != null ? p.eta : 0);
|
||||
if (p.chars > 0 && total > 0) rate = total / p.chars;
|
||||
}
|
||||
const rows = [];
|
||||
if (groups.done.length) {
|
||||
rows.push('<tr class="grp-row"><td colspan="4">✓ Обработанные (' + groups.done.length + ')</td></tr>');
|
||||
for (const i of groups.done) {
|
||||
const p = procState[i];
|
||||
const txt = p.st === 'skipped'
|
||||
? '<span style="color:#c0392b;" title="Не удалось прочитать файл: пустой, повреждённый или скан без текста">не извлечён</span>'
|
||||
: '<span style="color:#22c55e;">✓ ' + (p.elapsed ? p.elapsed.toFixed(1) : '0.0') + 'с</span>';
|
||||
rows.push(procRow(state, i, txt));
|
||||
}
|
||||
}
|
||||
if (groups.over.length) {
|
||||
rows.push('<tr class="grp-row"><td colspan="4">⛔ Пропущены (сверх лимита) (' + groups.over.length + ')</td></tr>');
|
||||
for (const i of groups.over) {
|
||||
rows.push(procRow(state, i, '<span style="color:#c0392b;">пропущен (лимит)</span>'));
|
||||
}
|
||||
}
|
||||
if (groups.current.length) {
|
||||
rows.push('<tr class="grp-row"><td colspan="4">▶ Текущий файл</td></tr>');
|
||||
for (const i of groups.current) {
|
||||
const p = procState[i];
|
||||
const cur = ((performance.now() - p.t0) / 1000).toFixed(1);
|
||||
const eta = (p.eta != null) ? ' / ~' + fmtSec(p.eta) : '';
|
||||
rows.push(procRow(state, i, '<span style="color:#2563eb;">⏳ ' + cur + 'с' + eta + '</span>'));
|
||||
}
|
||||
}
|
||||
if (groups.pending.length) {
|
||||
rows.push('<tr class="grp-row"><td colspan="4">○ Ожидают обработки (' + groups.pending.length + ')</td></tr>');
|
||||
for (const i of groups.pending) {
|
||||
const p = procState[i] || {};
|
||||
// Оценка: LLM (chars x rate) если известна; иначе грубая по размеру/скорости извлечения
|
||||
if (rate && !p.est && p.chars > 0) p.est = p.chars * rate;
|
||||
if (!p.est) {
|
||||
const szMB = (state.files[i] ? state.files[i].size : 0) / 1048576;
|
||||
const k = (state.proc && state.proc.procExtractRate) || cfg.estMbSec || DEFAULT_EST_MB_SEC; // сек/МБ
|
||||
p.est = Math.max(1, Math.round(szMB * k));
|
||||
}
|
||||
let txt;
|
||||
if (p.st === 'analyzed') txt = '<span style="color:#7d3c98;">анализ ✓</span>';
|
||||
else if (p.st === 'current') txt = '<span style="color:#2563eb;">⏳</span>';
|
||||
else if (p.est != null) txt = '<span style="color:#999;">~' + fmtSec(p.est) + '</span>';
|
||||
else txt = '<span style="color:#999;">—</span>';
|
||||
rows.push(procRow(state, i, txt));
|
||||
}
|
||||
}
|
||||
els.tableBody.innerHTML = rows.join('');
|
||||
const cntDone = groups.done.length;
|
||||
els.countEl.textContent = 'Готово ' + cntDone + ' / ' + state.files.length + ' файлов';
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
// rmFile — удалить файл из списка (если не busy).
|
||||
|
||||
import { render } from './render.js';
|
||||
|
||||
export function rmFile(state, els, cfg, i) {
|
||||
if (state.busy) return;
|
||||
const nm = state.files[i].name;
|
||||
state.overNames.delete(nm);
|
||||
state.fileMeta.delete(nm);
|
||||
state.files.splice(i, 1);
|
||||
// Синхронизировать input.files (DataTransfer) — чтобы повторный выбор того же
|
||||
// файла сработал (change не сработает, если files не обновлён).
|
||||
if (els.fileInput && els.fileInput.files) {
|
||||
const d = new DataTransfer();
|
||||
state.files.forEach(f => d.items.add(f));
|
||||
els.fileInput.files = d.files;
|
||||
}
|
||||
render(state, els, cfg);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// setStatus — записать HTML-статус в ячейку таблицы (st-<idx>).
|
||||
|
||||
export function setStatus(idx, html) {
|
||||
const e = document.getElementById('st-' + idx);
|
||||
if (e) e.innerHTML = html;
|
||||
}
|
||||
@@ -1,344 +1,110 @@
|
||||
// Юнит-тесты фронта модуля upload: zip (кириллица, вложенный zip, не-doc) + дедуп/лимиты.
|
||||
// Запуск: node upload/frontend/test_upload_frontend.mjs
|
||||
|
||||
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') {
|
||||
globalThis.File = class File extends Blob {
|
||||
constructor(parts, name, opts) {
|
||||
super(parts, opts);
|
||||
constructor(parts, name, options = {}) {
|
||||
super(parts, options);
|
||||
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';
|
||||
import { parseZip } from './zip/parse_zip.js';
|
||||
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;
|
||||
function bytes(text) {
|
||||
return new TextEncoder().encode(text);
|
||||
}
|
||||
|
||||
// ── Сборка минимального ZIP (method 0, UTF-8-флаг) ──
|
||||
function makeZip(entries) {
|
||||
const enc = new TextEncoder();
|
||||
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;
|
||||
function withRelativePath(file, path) {
|
||||
Object.defineProperty(file, 'webkitRelativePath', { value: path });
|
||||
return file;
|
||||
}
|
||||
|
||||
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 deflateRaw(bytes) {
|
||||
// zlib.deflateRawSync — deflate без zlib-заголовка (то, что ждёт inflateRaw)
|
||||
return deflateRawSync(Buffer.from(bytes));
|
||||
}
|
||||
|
||||
async function makeZipDeflate(entries) {
|
||||
const enc = new TextEncoder();
|
||||
const chunks = [];
|
||||
const central = [];
|
||||
let offset = 0;
|
||||
const utf8Flag = 0x0800;
|
||||
for (const e of entries) {
|
||||
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' },
|
||||
async function testRecursiveZipTree() {
|
||||
const nested = zipSync({ 'inside.pdf': bytes('nested') });
|
||||
const archive = zipSync({
|
||||
'contracts/one.pdf': bytes('one'),
|
||||
'contracts/ignore.txt': bytes('text'),
|
||||
'nested.zip': nested,
|
||||
'../escape.pdf': bytes('unsafe'),
|
||||
});
|
||||
const tree = await listZipFiles(new File([archive], 'outer.zip'), ['.pdf', '.doc', '.docx']);
|
||||
const files = flattenFiles([tree]);
|
||||
assert.deepEqual(files.map((file) => file.path).sort(), [
|
||||
'outer.zip/contracts/one.pdf',
|
||||
'outer.zip/nested.zip/inside.pdf',
|
||||
]);
|
||||
const files = await listZipFiles(new File([outer], 'outer.zip'), ALLOWED);
|
||||
const names = files.map(f => f.name).sort();
|
||||
assert.deepEqual(names, ['inner.txt', 'skip.txt']);
|
||||
console.log(' вложенный zip: ok');
|
||||
assert.equal(files.find((file) => file.name === 'inside.pdf').file.name, 'inside.pdf');
|
||||
}
|
||||
|
||||
async function testZipNonDoc() {
|
||||
const zip = makeZip([
|
||||
{ name: 'doc.pdf', data: 'pdf' },
|
||||
{ name: 'img.png', data: 'png' },
|
||||
{ name: 'readme.txt', data: 'readme' },
|
||||
]);
|
||||
const files = await listZipFiles(new File([zip], 'a.zip'), ALLOWED);
|
||||
const names = files.map(f => f.name).sort();
|
||||
assert.deepEqual(names, ['doc.pdf', 'readme.txt']);
|
||||
console.log(' не-doc фильтр: ok');
|
||||
function testPathAndSizeDeduplication() {
|
||||
const state = { nodes: [], fileKeys: new Set() };
|
||||
const file = new File(['same'], 'a.pdf');
|
||||
const node = { id: 'one', kind: 'file', name: 'a.pdf', path: 'a.pdf', file, children: [] };
|
||||
assert.equal(addFileWithDedup(state, node), true);
|
||||
assert.equal(addFileWithDedup(state, {
|
||||
...node, id: 'two', file: new File(['same'], 'a.pdf'),
|
||||
}), false);
|
||||
assert.equal(addFileWithDedup(state, {
|
||||
...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() {
|
||||
// deflate-raw требует DecompressionStream('deflate-raw') — в браузере есть, в Node 18 нет
|
||||
let supported = true;
|
||||
try { new DecompressionStream('deflate-raw'); } catch (e) { supported = false; }
|
||||
if (!supported) {
|
||||
console.log(' zip deflate (метод 8): skip — Node 18 не поддерживает deflate-raw');
|
||||
return;
|
||||
}
|
||||
const content = 'Hello deflate world '.repeat(50);
|
||||
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 testObscureManifest() {
|
||||
const manifest = withRelativePath(new File(['contracts/**\n!**/skip.docx\n'], 'obscure.lst'), 'root/obscure.lst');
|
||||
const included = withRelativePath(new File(['a'], 'a.pdf'), 'root/contracts/a.pdf');
|
||||
const excluded = withRelativePath(new File(['b'], 'skip.docx'), 'root/contracts/skip.docx');
|
||||
const outside = withRelativePath(new File(['c'], 'outside.pdf'), 'root/other/outside.pdf');
|
||||
const result = await lstFilter([manifest, included, excluded, outside], ['.pdf', '.doc', '.docx', '.zip']);
|
||||
assert.equal(result.manifest, manifest);
|
||||
assert.deepEqual(result.files, [included]);
|
||||
assert.deepEqual(result.diagnostics, []);
|
||||
}
|
||||
|
||||
async function testParseZipNotZip() {
|
||||
await assert.rejects(() => parseZip(new Uint8Array([1, 2, 3])), /Не ZIP/);
|
||||
console.log(' не-ZIP бросок: ok');
|
||||
function testEscaping() {
|
||||
assert.equal(esc(`<script title="x">&'`), '<script title="x">&'');
|
||||
}
|
||||
|
||||
// ── Тесты addFileWithDedup ──
|
||||
function newState() {
|
||||
return { files: [], fileMeta: new Map(), overNames: new Set(), busy: false, proc: null };
|
||||
function testStatusAndDocumentActionsRenderSafely() {
|
||||
const node = {
|
||||
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, /<script>bad<\/script>/);
|
||||
assert.doesNotMatch(elements.tableBodyEl.innerHTML, /<script>bad/);
|
||||
assert.match(elements.tableBodyEl.innerHTML, /<img src=x>/);
|
||||
}
|
||||
|
||||
function testDedup() {
|
||||
const st = newState();
|
||||
const c = { maxFileBytes: 100, maxSessionBytes: 300 };
|
||||
// одинаковое имя+размер → дедуп (false), список не растёт
|
||||
assert.equal(addFileWithDedup(st, new File(['aaa'], 'a.txt'), c), true);
|
||||
assert.equal(st.files.length, 1);
|
||||
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>'), '<a href="x">&'</a>');
|
||||
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],
|
||||
const tests = [
|
||||
['recursive ZIP tree and unsafe paths', testRecursiveZipTree],
|
||||
['deduplication by path and size', testPathAndSizeDeduplication],
|
||||
['obscure.lst selection filter', testObscureManifest],
|
||||
['HTML escaping', testEscaping],
|
||||
['status and document action rendering', testStatusAndDocumentActionsRenderSafely],
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
for (const [name, fn] of TESTS) {
|
||||
try {
|
||||
await fn();
|
||||
console.log('PASS ' + name);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.error('FAIL ' + name + ': ' + err.message);
|
||||
for (const [name, test] of tests) {
|
||||
await test();
|
||||
console.log(`PASS ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.error(failed + ' тестов упало');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('Все фронт-тесты прошли');
|
||||
|
||||
@@ -1,199 +1,166 @@
|
||||
// Юнит-тесты слоя 2 (фронт): putToVm + uploadViaVM с моками XMLHttpRequest и fetch.
|
||||
// Запуск: node upload/frontend/test_upload_layer2.mjs
|
||||
|
||||
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 { 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
|
||||
globalThis.crypto = globalThis.crypto || { randomUUID: () => 'test-uuid-1234' };
|
||||
|
||||
// ── мок XMLHttpRequest ──
|
||||
let lastXHR = null;
|
||||
let lastXHR;
|
||||
class FakeXHR {
|
||||
constructor() {
|
||||
this.upload = {};
|
||||
this.status = 0;
|
||||
this.statusText = '';
|
||||
this.timeout = 0;
|
||||
this.onload = null;
|
||||
this.onerror = null;
|
||||
this.ontimeout = null;
|
||||
this.method = null;
|
||||
this.url = null;
|
||||
this.body = null;
|
||||
lastXHR = this;
|
||||
}
|
||||
open(method, url) { this.method = method; this.url = url; }
|
||||
send(body) { this.body = body; }
|
||||
open(method, url) {
|
||||
this.method = method;
|
||||
this.url = url;
|
||||
}
|
||||
send(body) {
|
||||
this.body = body;
|
||||
}
|
||||
complete(status = 200) {
|
||||
this.status = status;
|
||||
this.onload();
|
||||
}
|
||||
}
|
||||
globalThis.XMLHttpRequest = FakeXHR;
|
||||
|
||||
// ── мок fetch ──
|
||||
let fetchCalls = [];
|
||||
function installFetch(respond) {
|
||||
fetchCalls = [];
|
||||
globalThis.fetch = async (url, opts) => {
|
||||
fetchCalls.push({ url, opts });
|
||||
return respond();
|
||||
let fetchResponder;
|
||||
globalThis.fetch = async (url, options) => {
|
||||
fetchCalls.push({ url, options });
|
||||
return fetchResponder(fetchCalls.length);
|
||||
};
|
||||
|
||||
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() {
|
||||
const f = new File(['abc'], 'a.txt');
|
||||
const p = putToVm(f, 'https://vm/tok_0');
|
||||
lastXHR.status = 200;
|
||||
lastXHR.onload();
|
||||
await p;
|
||||
const file = new File(['payload'], 'a.pdf');
|
||||
const request = putToVm(file, 'https://vm.example/a');
|
||||
assert.equal(lastXHR.method, 'PUT');
|
||||
assert.equal(lastXHR.url, 'https://vm/tok_0');
|
||||
assert.equal(lastXHR.body, f);
|
||||
console.log(' putToVm success: ok');
|
||||
assert.equal(lastXHR.body, file);
|
||||
lastXHR.complete();
|
||||
await request;
|
||||
}
|
||||
|
||||
async function testPutToVmHttpError() {
|
||||
const p = putToVm(new File(['abc'], 'a.txt'), 'https://vm/tok_0');
|
||||
lastXHR.status = 500;
|
||||
lastXHR.onload();
|
||||
await assert.rejects(p, /ВМ: HTTP 500/);
|
||||
console.log(' putToVm HTTP error: ok');
|
||||
}
|
||||
|
||||
async function testPutToVmNetworkError() {
|
||||
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); },
|
||||
async function testUploadIncludesPerFilePayloadAndResult() {
|
||||
fetchCalls = [];
|
||||
fetchResponder = async (callNumber) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
results: [{ ok: true, doc_id: `doc-${callNumber}`, parsed: { status: 'parsed' } }],
|
||||
}),
|
||||
});
|
||||
await tick();
|
||||
lastXHR.status = 200; lastXHR.onload();
|
||||
await p;
|
||||
assert.ok(statuses.some(s => s.html.includes('✓')));
|
||||
assert.ok(texts.some(t => t.includes('Загрузка на ВМ')));
|
||||
assert.ok(texts.some(t => t.includes('Передача ссылок')));
|
||||
console.log(' uploadViaVM progress/статусы: ok');
|
||||
const files = [
|
||||
{ id: 'one', name: 'one.pdf', path: 'one.pdf', file: new File(['1'], 'one.pdf') },
|
||||
{ id: 'two', name: 'two.pdf', path: 'archive.zip/folder/two.pdf', file: new File(['2'], 'two.pdf') },
|
||||
];
|
||||
const completed = [];
|
||||
const previous = lastXHR;
|
||||
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() {
|
||||
// session из options пробрасывается в POST-тело
|
||||
installFetch(() => ({ ok: true, json: async () => ({ ok: true, session: 'prev', count: 1 }) }));
|
||||
const files = [new File(['a'], 'a.txt')];
|
||||
const p = uploadViaVM(files, 'https://vm/', { session: 'existingsid' });
|
||||
await tick();
|
||||
lastXHR.status = 200; lastXHR.onload();
|
||||
await p;
|
||||
const body = JSON.parse(fetchCalls[0].opts.body);
|
||||
assert.equal(body.session, 'existingsid');
|
||||
console.log(' uploadViaVM проброс session: ok');
|
||||
async function testUploadReportsPutFailure() {
|
||||
fetchCalls = [];
|
||||
const previous = lastXHR;
|
||||
const request = uploadViaVM([new File(['x'], 'x.pdf')], {
|
||||
vmUploadUrl: 'https://vm.example/',
|
||||
});
|
||||
const xhr = await waitForNextXHR(previous);
|
||||
xhr.onerror();
|
||||
const result = await request;
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(fetchCalls.length, 0);
|
||||
assert.match(result.error, /Ошибка отправки файла/);
|
||||
}
|
||||
|
||||
// ── прогон ──
|
||||
const TESTS = [
|
||||
['putToVm success', testPutToVmSuccess],
|
||||
['putToVm HTTP error', testPutToVmHttpError],
|
||||
['putToVm network error', testPutToVmNetworkError],
|
||||
['putToVm timeout', testPutToVmTimeout],
|
||||
['putToVm onXHR', testPutToVmOnXHR],
|
||||
['uploadViaVM success', testUploadViaVMSuccess],
|
||||
['uploadViaVM PUT error', testUploadViaVMPutError],
|
||||
['uploadViaVM POST error', testUploadViaVMPostError],
|
||||
['uploadViaVM progress', testUploadViaVMProgress],
|
||||
['uploadViaVM проброс session', testUploadViaVMSessionPassed],
|
||||
async function testUploadReportsBackendFailure() {
|
||||
fetchCalls = [];
|
||||
fetchResponder = async () => ({
|
||||
ok: false,
|
||||
status: 502,
|
||||
statusText: 'Bad Gateway',
|
||||
json: async () => ({ ok: false, error: 'failed' }),
|
||||
});
|
||||
const previous = lastXHR;
|
||||
const request = uploadViaVM([new File(['x'], 'x.pdf')], {
|
||||
vmUploadUrl: 'https://vm.example/',
|
||||
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, fn] of TESTS) {
|
||||
try { await fn(); console.log('PASS ' + name); }
|
||||
catch (e) { failed++; console.error('FAIL ' + name + ': ' + e.message); }
|
||||
for (const [name, test] of tests) {
|
||||
await test();
|
||||
console.log(`PASS ${name}`);
|
||||
}
|
||||
if (failed) { console.error(failed + ' тестов упало'); process.exit(1); }
|
||||
console.log('Все тесты слоя 2 (фронт) прошли');
|
||||
|
||||
@@ -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 = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
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.timeout = options.timeoutMs || 300000; // 300с: большие файлы
|
||||
xhr.upload.onprogress = function(e) {
|
||||
xhr.timeout = options.timeoutMs || 300000;
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
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();
|
||||
else reject(new Error('ВМ: HTTP ' + xhr.status));
|
||||
|
||||
xhr.onload = () => {
|
||||
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.send(file); // сырое тело файла
|
||||
|
||||
xhr.onerror = () => {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,59 +1,200 @@
|
||||
// uploadViaVM — слой 2 (фронт): закачать файлы через ВМ-буфер.
|
||||
// Шаг 1: PUT каждого файла на ВМ (token-ключ в URL). Шаг 2: POST /api/upload_refs.
|
||||
//
|
||||
// files: File[] — ТОЛЬКО учитываемые (без сверхлимитных).
|
||||
// vmUploadUrl: базовый URL ВМ-буфера (напр. "https://.../drhider-upload/").
|
||||
// options: {
|
||||
// session, // текущий sid (или '')
|
||||
// apiBase, // базовый путь бэка, по умолчанию '' (тот же origin)
|
||||
// onUploadStatus, // (text) — сообщение статуса
|
||||
// onStatus, // (k, html) — статус в таблице для k-го файла
|
||||
// }
|
||||
// Returns: Promise<{ok, session, count} | {ok:false, error}>
|
||||
/**
|
||||
* uploadViaVM — Слой 2 (Фронтенд): пофайловый транзит через ВМ-буфер в сессию бэкенда.
|
||||
*
|
||||
* Архитектура потокового транзита:
|
||||
* Для каждого файла k:
|
||||
* 1. Браузер отправляет файл k методом PUT в буфер на ВМ.
|
||||
* 2. Браузер сразу делает POST /api/upload_refs для одного файла k.
|
||||
* 3. Бэкенд забирает файл исходящим GET в память RAM и сразу шлёт DELETE в буфер.
|
||||
* 4. Файл удалён из буфера ВМ, память чиста.
|
||||
* 5. Браузер переходит к файлу k+1.
|
||||
*/
|
||||
|
||||
import { putToVm } from './put_to_vm.js';
|
||||
import { fs } from '../table/fs.js';
|
||||
|
||||
export async function uploadViaVM(files, vmUploadUrl, options = {}) {
|
||||
const token = crypto.randomUUID();
|
||||
const refs = []; // {name, size, url} — для POST /api/upload_refs
|
||||
const total = files.length;
|
||||
for (let k = 0; k < total; k++) {
|
||||
const f = files[k];
|
||||
const vmUrl = vmUploadUrl + token + '_' + k; // имя файла в URL не несём (токен-ключ)
|
||||
if (options.onUploadStatus) {
|
||||
options.onUploadStatus('Загрузка на ВМ (этап 1/2) ' + (k + 1) + '/' + total + ': ' + f.name);
|
||||
function generateUuid() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
try {
|
||||
const t0 = performance.now();
|
||||
await putToVm(f, vmUrl, {
|
||||
onProgress(pct) {
|
||||
if (options.onStatus) options.onStatus(k, '<span style="color:#2563eb">⏳ ' + pct + '%</span>');
|
||||
},
|
||||
onXHR: options.onXHR,
|
||||
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++) {
|
||||
if (options.signal?.aborted) {
|
||||
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
|
||||
}
|
||||
|
||||
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 {
|
||||
await putToVm(file, fileUrl, {
|
||||
signal: options.signal,
|
||||
onXHR: options.onXHR,
|
||||
onProgress(pct, loaded, fileTotal) {
|
||||
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 });
|
||||
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 });
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (options.onStatus) options.onStatus(k, '<span style="color:#ef4444">✗</span>');
|
||||
return { ok: false, error: 'Ошибка загрузки на ВМ: ' + err.message };
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// Этап 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.onUploadStatus) options.onUploadStatus('Передача ссылок в сервис…');
|
||||
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 };
|
||||
if (options.onStatus) {
|
||||
options.onStatus(`Завершено. Успешно передано файлов: ${total}`);
|
||||
}
|
||||
|
||||
return { ok: true, session: currentSession, count: totalAdded };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
const buf = new Uint8Array(await file.arrayBuffer());
|
||||
const entries = await parseZip(buf);
|
||||
const out = [];
|
||||
// Из ZIP вытаскиваем только документы. Всё прочее (изображения и т.п.) пропускаем.
|
||||
for (const e of entries) {
|
||||
if (e.isDir) continue;
|
||||
const low = e.name.toLowerCase();
|
||||
if (low.endsWith('.zip')) {
|
||||
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 }));
|
||||
/** Возвращает true, если имя заканчивается одним из разрешённых расширений. */
|
||||
function extensionAllowed(name, allowedExt) {
|
||||
const lowerName = name.toLowerCase();
|
||||
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
|
||||
}
|
||||
// иначе — не документ, пропускаем
|
||||
|
||||
/** Создаёт browser File из байтов ZIP entry с базовым именем файла. */
|
||||
function makeFile(data, name) {
|
||||
return new File([data], name.split('/').at(-1));
|
||||
}
|
||||
return out;
|
||||
|
||||
/**
|
||||
* Валидирует путь ZIP entry до его использования в дереве.
|
||||
*
|
||||
* @param {string} entryName Сырой путь из центрального каталога ZIP.
|
||||
* @returns {string[]|null} Безопасные сегменты пути или null для опасной записи.
|
||||
*
|
||||
* Отклоняются абсолютные пути, backslash, пустые сегменты, `.` и `..`.
|
||||
* Это предотвращает traversal и появление ложных групп в UI.
|
||||
*/
|
||||
function safeEntryParts(entryName) {
|
||||
if (!entryName || entryName.startsWith('/') || entryName.includes('\\')) return null;
|
||||
const parts = entryName.split('/');
|
||||
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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user