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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Repinoid
2026-10-30 10:24:23 +03:00
co-authored by Copilot
parent 83002b9644
commit 6e6fc4db8e
41 changed files with 3746 additions and 1485 deletions
+8 -6
View File
@@ -21,13 +21,15 @@
## 2. Загрузка файлов
Фронтенд начинает обработку в [`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
View File
@@ -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
View File
@@ -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")
+10
View File
@@ -9,6 +9,10 @@ _UPLOAD_FRONTEND = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"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
View File
@@ -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');
+4 -11
View File
@@ -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;
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Arjun Barrett
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because it is too large Load Diff
+253 -264
View File
@@ -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
View File
@@ -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>
+48
View File
@@ -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
+69
View File
@@ -0,0 +1,69 @@
import os
import sys
from flask import Flask
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
def test_sink_receives_contract_metadata_without_creating_session(monkeypatch):
from upload.backend.upload_refs import blueprint
calls = []
class FakeHttpClient:
def __init__(self, **_kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def delete(self, _url):
return None
def sink(name, content, **metadata):
calls.append((name, content, metadata))
return {"ok": True, "doc_id": "doc-1"}
def unexpected_create_session():
raise AssertionError("sink mode must not create a session")
monkeypatch.setattr(blueprint.httpx, "Client", FakeHttpClient)
monkeypatch.setattr(blueprint, "pull_file", lambda *_args, **_kwargs: b"document")
monkeypatch.setattr(blueprint, "create_session", unexpected_create_session)
app = Flask(__name__)
app.register_blueprint(blueprint.create_upload_refs_blueprint(
{"apiPrefix": "/api", "vmUploadPrefix": "https://vm.example/uploads/"},
sink=sink,
))
response = app.test_client().post("/api/upload_refs", json={
"batch_id": "batch-1",
"contract_id": "contract-1",
"zip_source": "folder/archive.zip",
"files": [{
"name": "agreement.pdf",
"size": 8,
"url": "https://vm.example/uploads/token",
}],
})
assert response.status_code == 200
assert response.get_json()["results"] == [
{"name": "agreement.pdf", "ok": True, "doc_id": "doc-1"}
]
assert calls == [(
"agreement.pdf",
b"document",
{
"batch_id": "batch-1",
"contract_id": "contract-1",
"zip_source": "folder/archive.zip",
},
)]
+56 -83
View File
@@ -30,49 +30,49 @@ upload/
## Подключение фронта
Подключить ES-модули (`<script type="module">`) и собрать слой 1:
Исходники — ES-модули. Для обычной страницы соберите IIFE-бандл:
```js
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`.
---
+1 -1
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
node_modules/
+245
View File
@@ -0,0 +1,245 @@
import { addFiles, onFilesChange } from './table/on_files_change.js';
import { onFolderChange } from './table/on_folder_change.js';
import { findNode, flattenFiles, render } from './table/render.js';
import { esc } from './table/esc.js';
const DEFAULTS = {
allowedExt: [],
labels: {
pickFiles: 'Выбрать файлы',
pickFolder: 'Выбрать папку',
clear: 'Очистить',
empty: 'Нет выбранных файлов',
statusReady: 'готов',
remove: 'Удалить',
details: 'Классификация',
text: 'Текст документа',
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
count: (count, bytes) => `${count} файлов · ${bytes} B`,
},
layout: { columns: ['path', 'size', 'status'], controls: ['files', 'folder', 'clear'] },
limits: {
maxEntries: 1000,
maxTotalBytes: 100 * 1024 * 1024,
maxEntryBytes: 50 * 1024 * 1024,
maxDepth: 20,
},
};
function resolveMount(mount) {
const element = typeof mount === 'string' ? document.querySelector(mount) : mount;
if (!element) throw new Error('File picker mount element не найден');
return element;
}
function normalizeConfig(config) {
return {
...config,
allowedExt: config.allowedExt || DEFAULTS.allowedExt,
labels: {
...DEFAULTS.labels,
...(config.labels || {}),
columns: { ...DEFAULTS.labels.columns, ...(config.labels?.columns || {}) },
},
layout: { ...DEFAULTS.layout, ...(config.layout || {}) },
limits: { ...DEFAULTS.limits, ...(config.limits || {}) },
};
}
function injectStyles() {
if (document.querySelector('style[data-file-picker]')) return;
const style = document.createElement('style');
style.dataset.filePicker = '';
style.textContent = `
.file-picker { color: #17211b; font-family: Georgia, 'Times New Roman', serif; }
.file-picker .fp-toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }
.file-picker .fp-toolbar button { border: 0; border-radius: 4px; padding: 12px 17px; color: #f7faf4; background: #1f5a3b; font: 700 13px sans-serif; cursor: pointer; }
.file-picker .fp-toolbar button.secondary { color: #1f5a3b; background: #cbdcc9; }
.file-picker .fp-toolbar button.quiet { color: #5d6a61; background: transparent; }
.file-picker .fp-table-wrap { overflow-x: auto; border-top: 1px solid #b9c7ba; }
.file-picker .fp-table { width: 100%; border-collapse: collapse; font: 14px/1.4 sans-serif; }
.file-picker .fp-table th, .file-picker .fp-table td { padding: 14px 10px; border-bottom: 1px solid #cbd5cc; text-align: left; }
.file-picker .fp-table th { color: #56745f; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
.file-picker .fp-status, .file-picker .fp-count { color: #5d6a61; font: 13px sans-serif; }
.file-picker .fp-count { min-height: 22px; }
.file-picker .tree-name { display: inline-flex; align-items: center; gap: 8px; min-width: 240px; padding-top: 0; padding-bottom: 0; border: 0; background: transparent; color: #17211b; font: inherit; text-align: left; }
.file-picker .tree-group { padding-left: 0; cursor: pointer; }
.file-picker .tree-chevron { display: inline-block; width: 12px; color: #78917d; font-size: 10px; }
.file-picker .remove-btn { padding: 2px 7px; color: #8c5148; background: transparent; font: 20px/1 sans-serif; }
.file-picker .tree-row td:first-child { white-space: nowrap; }
@media (max-width: 600px) { .file-picker .fp-toolbar button { flex: 1 1 42%; } }
`;
document.head.append(style);
}
function buildMarkup(root, cfg) {
const controls = cfg.layout.controls || [];
const control = (name, id, label, className = '') => controls.includes(name)
? `<button id="${id}" type="button" class="${className}">${esc(label)}</button>` : '';
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns);
const headings = columns.map((column) => `<th>${esc(cfg.labels.columns[column] || column)}</th>`).join('');
// Обычный file input не принимает manifest: `.lst` ему не нужен.
const fileAccept = esc([...new Set([...cfg.allowedExt, '.zip'])].join(','));
// Folder input дополнительно объявляет `.lst`: при webkitdirectory Chromium
// обычно игнорирует accept, но явное расширение служит страховкой, чтобы
// `obscure.lst` попал в FileList. Сам файл в дерево не добавляется.
const folderAccept = esc([...new Set([...cfg.allowedExt, '.zip', '.lst'])].join(','));
root.innerHTML = `
<div class="file-picker${cfg.layout.theme ? ` ${esc(cfg.layout.theme)}` : ''}">
<div class="fp-toolbar" aria-label="${esc(cfg.labels.pickFiles)}">
<input class="fp-file-input" type="file" accept="${fileAccept}" multiple hidden>
<input class="fp-folder-input" type="file" accept="${folderAccept}" webkitdirectory directory multiple hidden>
${control('files', 'fp-files-btn', cfg.labels.pickFiles)}
${control('folder', 'fp-folder-btn', cfg.labels.pickFolder, 'secondary')}
${control('clear', 'fp-clear-btn', cfg.labels.clear, 'quiet')}
</div>
<p class="fp-status" role="status"></p>
<div class="fp-table-wrap"><table class="fp-table"><thead><tr>${headings}<th></th></tr></thead><tbody class="fp-table-body"></tbody></table></div>
<p class="fp-count"></p>
</div>`;
}
function notify(cfg, state) {
if (typeof cfg.onChange === 'function') cfg.onChange([...flattenFiles(state.nodes)]);
}
function removeMetadata(state, node) {
if (node.kind === 'file') {
state.fileKeys.delete(`${node.path}\u0000${node.file.size}`);
state.statuses.delete(node.id);
state.documents.delete(node.id);
return;
}
node.children.forEach((child) => removeMetadata(state, child));
}
function removeNode(state, id) {
const found = findNode(state.nodes, id);
if (!found) return false;
removeMetadata(state, found.node);
found.nodes.splice(found.nodes.indexOf(found.node), 1);
return true;
}
export function initFilePicker(config) {
const cfg = normalizeConfig(config);
const mount = resolveMount(cfg.mount);
injectStyles();
buildMarkup(mount, cfg);
const root = mount.firstElementChild;
const elements = {
fileInputEl: root.querySelector('.fp-file-input'),
folderInputEl: root.querySelector('.fp-folder-input'),
statusEl: root.querySelector('.fp-status'),
tableBodyEl: root.querySelector('.fp-table-body'),
countEl: root.querySelector('.fp-count'),
};
const state = {
nodes: [],
fileKeys: new Set(),
busy: false,
statuses: new Map(),
documents: new Map(),
};
const listeners = [];
const listen = (target, event, handler) => {
target.addEventListener(event, handler);
listeners.push(() => target.removeEventListener(event, handler));
};
const redraw = () => render(state, elements, cfg);
const filesChange = onFilesChange(state, cfg, elements);
const folderChange = onFolderChange(state, cfg, elements);
listen(elements.fileInputEl, 'change', async () => { await filesChange(); notify(cfg, state); });
listen(elements.folderInputEl, 'change', async () => { await folderChange(); notify(cfg, state); });
listen(elements.tableBodyEl, 'click', (event) => {
const toggle = event.target.closest('[data-toggle]');
if (toggle) {
const found = findNode(state.nodes, toggle.dataset.toggle);
if (found) found.node.expanded = !found.node.expanded;
redraw();
return;
}
const details = event.target.closest('[data-details]');
if (details) {
if (typeof cfg.onDetails === 'function') cfg.onDetails(details.dataset.details);
return;
}
const text = event.target.closest('[data-text]');
if (text) {
if (typeof cfg.onText === 'function') cfg.onText(text.dataset.text);
return;
}
const remove = event.target.closest('[data-remove]');
if (remove) {
if (removeNode(state, remove.dataset.remove)) {
redraw();
notify(cfg, state);
}
}
});
if (root.querySelector('#fp-files-btn')) listen(root.querySelector('#fp-files-btn'), 'click', () => elements.fileInputEl.click());
if (root.querySelector('#fp-folder-btn')) listen(root.querySelector('#fp-folder-btn'), 'click', () => elements.folderInputEl.click());
if (root.querySelector('#fp-clear-btn')) listen(root.querySelector('#fp-clear-btn'), 'click', () => {
state.nodes = [];
state.fileKeys.clear();
state.statuses.clear();
state.documents.clear();
if (elements.statusEl) elements.statusEl.textContent = '';
redraw();
notify(cfg, state);
});
redraw();
return {
pickFiles: () => elements.fileInputEl.click(),
pickFolder: () => elements.folderInputEl.click(),
addFiles: async (files) => { await addFiles(state, cfg, files, elements); notify(cfg, state); },
getFiles: () => [...flattenFiles(state.nodes)],
setStatus: (id, status) => {
if (!findNode(state.nodes, id)) return false;
state.statuses.set(id, {
kind: String(status?.kind || 'ready'),
text: String(status?.text || ''),
});
redraw();
return true;
},
setDocument: (id, documentId) => {
if (!findNode(state.nodes, id)) return false;
if (documentId) state.documents.set(id, String(documentId));
else state.documents.delete(id);
redraw();
return true;
},
setMessage: (message) => {
if (elements.statusEl) elements.statusEl.textContent = String(message || '');
},
remove: (id) => {
if (removeNode(state, id)) {
redraw();
notify(cfg, state);
}
},
clear: () => {
state.nodes = [];
state.fileKeys.clear();
state.statuses.clear();
state.documents.clear();
if (elements.statusEl) elements.statusEl.textContent = '';
redraw();
notify(cfg, state);
},
render: redraw,
destroy: () => {
listeners.forEach((removeListener) => removeListener());
state.nodes = [];
state.fileKeys.clear();
state.statuses.clear();
state.documents.clear();
mount.replaceChildren();
},
};
}
export { putToVm } from './upload/put_to_vm.js';
export { uploadViaVM } from './upload/upload_via_vm.js';
export { DEFAULTS };
+10 -1
View File
@@ -2,5 +2,14 @@
"name": "upload-frontend",
"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"
}
}
+49 -42
View File
@@ -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);
});
}
+17 -4
View File
@@ -1,5 +1,18 @@
// esc — экранирование HTML (защита от self-XSS именами файлов).
export function esc(s) {
return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
/**
* Экранирует текст перед вставкой в HTML-шаблон, собираемый через innerHTML.
*
* @param {*} value Имя файла, путь или сообщение статуса.
* @returns {string} Строка с заменёнными HTML-значимыми символами.
*
* Экранируются и кавычки, потому что значение может попасть не только в текст
* кнопки, но и в HTML-атрибут вроде data-path или aria-label.
*/
export function esc(value) {
return String(value).replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[character]));
}
-8
View File
@@ -1,8 +0,0 @@
// estForFile — эмпирическая оценка времени обработки файла: сек/МБ (ориентировочно, до старта).
export const DEFAULT_EST_MB_SEC = 12;
export function estForFile(f, estMbSec) {
const k = estMbSec || DEFAULT_EST_MB_SEC;
return f ? Math.max(1, Math.round(f.size / 1048576 * k)) : 0;
}
-6
View File
@@ -1,6 +0,0 @@
// fmtSec — формат секунд: "Nс" / "Nм Nс".
export function fmtSec(s) {
s = Math.max(0, Math.round(s));
return s >= 60 ? Math.floor(s / 60) + 'м ' + (s % 60) + 'с' : s + 'с';
}
+10 -6
View File
@@ -1,7 +1,11 @@
// fs — формат размера: B / KB / MB.
export function fs(b) {
return b < 1024 ? b + ' B'
: 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); },
};
}
+394
View File
@@ -0,0 +1,394 @@
/**
* lst_filter — фильтрация файлов каталога по manifest-файлу `obscure.lst`.
*
* Модуль намеренно чистый: не обращается к DOM, сети, upload или Flask.
* На вход получает список File из folder picker и набор поддерживаемых
* расширений (включая '.zip'), на выходе отдаёт отфильтрованный список File
* в исходном порядке и диагностику для интерфейса.
*
* Формат manifest:
* - одна строка — одно правило;
* - пустые строки игнорируются;
* - строки, начинающиеся с '#', — комментарии;
* - '!' в начале строки задаёт исключение;
* - правила задают пути относительно выбранной папки;
* - положительные правила собирают кандидатов, исключения удаляют совпадения.
*
* Glob-семантика:
* - '*' — ноль или больше символов в одном сегменте (не '/');
* - '?' — один символ, кроме '/';
* - '**' — целый сегмент: рекурсивное совпадение через каталоги;
* внутри сегмента ('a**b') эквивалентен '*';
* - '[abc]', '[a-z]' — классы и диапазоны символов;
* - '[!a]', '[^a]' — отрицательные классы символов;
* - 'reports/' эквивалентно 'reports/**';
* - путь без glob-символов — точное совпадение;
* - сравнение всего пути без учёта регистра.
*/
/** Фиксированное имя manifest-файла (ищется только в корне выбранной папки). */
const MANIFEST_NAME = 'obscure.lst';
/**
* Возвращает true, если имя файла оканчивается одним из разрешённых расширений.
*
* Сравнение без учёта регистра: 'A.PDF' и '.pdf' считаются одинаковыми.
*
* @param {string} name Имя файла.
* @param {string[]} allowedExt Список расширений (с точкой) включая '.zip'.
* @returns {boolean}
*/
function extensionAllowed(name, allowedExt) {
const lowerName = String(name || '').toLowerCase();
return allowedExt.some((extension) => lowerName.endsWith(String(extension).toLowerCase()));
}
/**
* Вычисляет путь файла относительно корня выбранной папки.
*
* Браузер для folder picker кладёт в `webkitRelativePath` путь вида
* `<корень>/подпапка/файл`; первый сегмент (имя выбранной папки) отбрасываем.
* Обратные слеши нормализуются в '/', чтобы правило можно было писать
* в любом стиле.
*
* @param {{webkitRelativePath?: string, name: string}} file Файл или его фейк.
* @returns {string} Путь относительно выбранной папки.
*/
function relativePathOf(file) {
const raw = String(file.webkitRelativePath || file.name || '').replace(/\\/g, '/');
const parts = raw.split('/');
return parts.length > 1 ? parts.slice(1).join('/') : (parts[0] || '');
}
/**
* Ищет ровно корневой `obscure.lst` (без учёта регистра).
*
* Manifest — это файл на глубине 1 от выбранной папки. Вложенный
* `subdir/obscure.lst` манифестом не считается; другие `*.lst` тоже.
*
* @param {Array} files Список файлов folder picker.
* @returns {object|null} Найденный manifest либо null.
*/
function findManifest(files) {
return files.find((file) => {
const raw = String(file.webkitRelativePath || file.name || '').replace(/\\/g, '/');
const parts = raw.split('/');
return parts.length === 2 && parts[1].toLowerCase() === MANIFEST_NAME;
}) || null;
}
/**
* Экранирует символы регулярного выражения, чтобы glob-литералы
* не превращались в regex-метасимволы.
*
* @param {string} text Произвольная строка.
* @returns {string} Строка, безопасная для вставки в RegExp.
*/
function escapeRegExp(text) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Находит закрывающую ']' для класса символов.
*
* Учитывает, что ']' сразу после '[' или '[!' / '[^' является литералом,
* а не закрывающей скобкой.
*
* @param {string} glob Исходный шаблон.
* @param {number} openIndex Позиция открывающей '['.
* @returns {number} Индекс закрывающей ']' либо -1, если класс не закрыт.
*/
function findClosingBracket(glob, openIndex) {
let index = openIndex + 1;
if (glob[index] === '!' || glob[index] === '^') index += 1;
if (glob[index] === ']') index += 1;
for (; index < glob.length; index += 1) {
if (glob[index] === ']') return index;
}
return -1;
}
/**
* Компилирует glob в регистронезависимый RegExp.
*
* @param {string} glob Нормализованный шаблон правила.
* @returns {RegExp|null} null, если шаблон синтаксически некорректен
* (например, незакрытый класс символов).
*/
function compileGlob(glob) {
let pattern = '^';
let index = 0;
while (index < glob.length) {
const character = glob[index];
if (character === '*') {
const isDouble = glob[index + 1] === '*';
if (!isDouble) {
// Одиночная звёздочка: любой набор символов внутри одного сегмента.
pattern += '[^/]*';
index += 1;
continue;
}
// Двойная звёздочка рекурсивна только как целый сегмент пути.
const atSegmentStart = index === 0 || glob[index - 1] === '/';
const atSegmentEnd = index + 2 === glob.length || glob[index + 2] === '/';
if (atSegmentStart && atSegmentEnd) {
if (glob[index + 2] === '/') {
// '**/' — ноль или больше сегментов вместе с завершающим '/'.
pattern += '(?:[^/]+/)*';
index += 3;
} else {
// '**' в конце правила — любой остаток пути.
pattern += '.*';
index += 2;
}
} else {
// 'a**b' внутри сегмента эквивалентно 'a*b' и не пересекает '/'.
pattern += '[^/]*';
index += 2;
}
continue;
}
if (character === '?') {
// Ровно один символ, но не разделитель каталогов.
pattern += '[^/]';
index += 1;
continue;
}
if (character === '[') {
const close = findClosingBracket(glob, index);
if (close === -1) return null;
let body = glob.slice(index + 1, close);
// '[!a]' — традиционная запись отрицательного класса, '[^a]' — regex-стиль.
if (body.startsWith('!')) body = `^${body.slice(1)}`;
pattern += `[${body.replace(/[\\\]]/g, (symbol) => `\\${symbol}`)}]`;
index = close + 1;
continue;
}
pattern += escapeRegExp(character);
index += 1;
}
pattern += '$';
try {
return new RegExp(pattern, 'i');
} catch (error) {
// Некорректный класс (например, '[]') Regexp не компилирует — правило
// отклоняется, остальные правила продолжают обрабатываться.
return null;
}
}
/**
* Нормализует одну строку manifest в путь правила.
*
* @param {string} line Исходная строка.
* @returns {null|{error: string}|{path: string, negative: boolean}}
* null — пустая строка или комментарий;
* {error} — некорректное правило;
* {path, negative} — нормализованное правило.
*/
function normalizeRule(line) {
let text = line.trim();
// Пустые строки и комментарии не являются правилами.
if (!text || text.startsWith('#')) return null;
let negative = false;
if (text.startsWith('!')) {
negative = true;
text = text.slice(1).trim();
// Голое '!' — ошибка с указанием номера строки.
if (!text) return { error: 'пустое правило после "!"' };
}
// Единый разделитель, схлопывание повторных '/' и устранение ведущих './'.
// Схлопывание идёт до снятия './', чтобы './/reports' превращалось в
// 'reports', а не в абсолютный '/reports'.
text = text.replace(/\\/g, '/');
text = text.replace(/\/{2,}/g, '/');
text = text.replace(/^(?:\.\/)+/, '');
// Абсолютные пути (POSIX и Windows drive) недопустимы.
if (text.startsWith('/') || /^[a-zA-Z]:\//.test(text)) {
return { error: 'абсолютный путь не допускается' };
}
// Выход за пределы выбранной папки запрещён.
if (text.split('/').some((segment) => segment === '..')) {
return { error: 'сегмент ".." не допускается' };
}
if (!text) return { error: 'пустое правило' };
// Правило 'reports/' означает всё поддерево reports.
if (text.endsWith('/')) text += '**';
return { path: text, negative };
}
/**
* Разбирает текст manifest в массив правил.
*
* Некорректные строки не прерывают разбор: они попадают в diagnostics
* с номером строки, остальные правила продолжают работать.
*
* @param {string} text Содержимое manifest (BOM уже снят).
* @param {Array} diagnostics Аккумулятор диагностики.
* @returns {Array<{line: number, source: string, negative: boolean, regex: RegExp}>}
*/
function parseRules(text, diagnostics) {
// Поддерживаем CRLF, LF и одиночный CR.
const lines = text.split(/\r\n|\r|\n/);
const rules = [];
lines.forEach((line, index) => {
const lineNumber = index + 1;
const normalized = normalizeRule(line);
if (normalized === null) return;
if (normalized.error) {
diagnostics.push({
level: 'error',
line: lineNumber,
message: `${normalized.error}: "${line.trim()}"`,
});
return;
}
const regex = compileGlob(normalized.path);
if (!regex) {
diagnostics.push({
level: 'error',
line: lineNumber,
message: `некорректный glob: "${line.trim()}"`,
});
return;
}
rules.push({
line: lineNumber,
source: line.trim(),
negative: normalized.negative,
regex,
});
});
return rules;
}
/**
* Фильтрует файлы каталога по manifest-файлу `obscure.lst`.
*
* Поведение:
* - нет корневого manifest — возвращаются все файлы (прежнее поведение);
* - manifest есть — результат строится только по его правилам, fallback нет;
* - сам manifest никогда не попадает в результат;
* - файлы неподдерживаемых типов не попадают в кандидаты;
* - ZIP фильтруется по имени архива, внутренние имена не анализируются.
*
* @param {FileList|File[]} files Файлы, выбранные через folder picker.
* @param {string[]} allowedExt Поддерживаемые расширения, включая '.zip'.
* @returns {Promise<{
* manifest: object|null,
* files: Array,
* diagnostics: Array<{level: string, line: number, message: string}>
* }>}
*/
export async function lstFilter(files, allowedExt) {
const list = Array.from(files || []);
const manifest = findManifest(list);
// Manifest отсутствует: сохраняем прежнее поведение без какой-либо фильтрации.
if (!manifest) {
return { manifest: null, files: list, diagnostics: [] };
}
const diagnostics = [];
// Ошибка чтения или UTF-8-декодирования: предупреждение и пустой результат.
// TextDecoder с fatal:true не подменяет битые байты на U+FFFD молча.
let text;
try {
const buffer = await manifest.arrayBuffer();
text = new TextDecoder('utf-8', { fatal: true }).decode(buffer);
} catch (error) {
diagnostics.push({
level: 'error',
line: 0,
message: `не удалось прочитать ${MANIFEST_NAME}: ${error.message}`,
});
return { manifest, files: [], diagnostics };
}
// BOM в начале снимаем вручную, иначе он попадёт в первое правило.
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
const rules = parseRules(text, diagnostics);
const positives = rules.filter((rule) => !rule.negative);
const negatives = rules.filter((rule) => rule.negative);
// Нет ни одного корректного включающего правила — пустой результат без fallback.
if (positives.length === 0) {
diagnostics.push({
level: 'warning',
line: 0,
message: `в ${MANIFEST_NAME} нет корректных включающих правил`,
});
return { manifest, files: [], diagnostics };
}
// Кандидаты: только поддерживаемые типы, без самого manifest.
// ZIP остаётся обычным кандидатом — include/exclude применяются к его имени.
const candidates = list.filter((file) => file !== manifest
&& extensionAllowed(file.name, allowedExt));
// Проход по файлам (а не по правилам) сохраняет исходный порядок и
// исключает появление дублей при пересекающихся масках.
const matched = new Set();
const selected = [];
for (const file of candidates) {
const path = relativePathOf(file);
let included = false;
for (const rule of positives) {
if (rule.regex.test(path)) {
matched.add(rule);
included = true;
}
}
if (!included) continue;
// Исключение применяется после положительного набора.
const excluded = negatives.some((rule) => rule.regex.test(path));
if (!excluded) selected.push(file);
}
// Правило, не совпавшее ни с одним поддерживаемым файлом, — предупреждение,
// остальные правила продолжают работать.
for (const rule of positives) {
if (!matched.has(rule)) {
diagnostics.push({
level: 'warning',
line: rule.line,
message: `правило не совпало ни с одним файлом: "${rule.source}"`,
});
}
}
if (selected.length === 0) {
diagnostics.push({
level: 'warning',
line: 0,
message: 'manifest не выбрал ни одного файла',
});
}
return { manifest, files: selected, diagnostics };
}
+62 -43
View File
@@ -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;
}
};
}
+91 -42
View File
@@ -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;
}
};
}
-13
View File
@@ -1,13 +0,0 @@
// procRow — строка таблицы обработки (3-секционная).
import { esc } from './esc.js';
import { fs } from './fs.js';
export function procRow(state, i, stTxt) {
const f = state.files[i];
const over = state.overNames.has(f.name);
const p = state.proc && state.proc.procState ? state.proc.procState[i] : null;
const cls = (p && p.st === 'current') ? ' class="row-current"'
: (over ? ' class="row-over"' : '');
return '<tr' + cls + '><td class="name-cell">' + esc(f.name) + '</td><td class="num-cell">' + fs(f.size) + '</td><td class="num-cell" style="font-size:12px;">' + stTxt + '</td><td></td></tr>';
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Добавляет префикс корневой папки к каждому пути дерева.
*
* @param {{path: string, file?: File, children: Array}} root Узел дерева.
* @param {string} prefix Путь выбранной папки, в которую попал узел.
* @returns {object} Тот же узел после изменения путей.
*
* Для leaf-файлов создаётся новый File с тем же базовым именем; логический путь
* хранится отдельно в узле дерева.
*/
export function rebaseTree(root, prefix) {
root.path = `${prefix}/${root.path}`;
if (root.file) root.file = new File([root.file], root.file.name, { lastModified: root.file.lastModified });
root.children.forEach((child) => rebaseTree(child, prefix));
return root;
}
+151 -23
View File
@@ -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 + ' файлов';
}
-19
View File
@@ -1,19 +0,0 @@
// rmFile — удалить файл из списка (если не busy).
import { render } from './render.js';
export function rmFile(state, els, cfg, i) {
if (state.busy) return;
const nm = state.files[i].name;
state.overNames.delete(nm);
state.fileMeta.delete(nm);
state.files.splice(i, 1);
// Синхронизировать input.files (DataTransfer) — чтобы повторный выбор того же
// файла сработал (change не сработает, если files не обновлён).
if (els.fileInput && els.fileInput.files) {
const d = new DataTransfer();
state.files.forEach(f => d.items.add(f));
els.fileInput.files = d.files;
}
render(state, els, cfg);
}
-6
View File
@@ -1,6 +0,0 @@
// setStatus — записать HTML-статус в ячейку таблицы (st-<idx>).
export function setStatus(idx, html) {
const e = document.getElementById('st-' + idx);
if (e) e.innerHTML = html;
}
+83 -317
View File
@@ -1,344 +1,110 @@
// Юнит-тесты фронта модуля upload: zip (кириллица, вложенный zip, не-doc) + дедуп/лимиты.
// Запуск: node upload/frontend/test_upload_frontend.mjs
import assert from 'node:assert/strict';
import { 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">&'`), '&lt;script title=&quot;x&quot;&gt;&amp;&#39;');
}
// ── Тесты 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, /&lt;script&gt;bad&lt;\/script&gt;/);
assert.doesNotMatch(elements.tableBodyEl.innerHTML, /<script>bad/);
assert.match(elements.tableBodyEl.innerHTML, /&lt;img src=x&gt;/);
}
function testDedup() {
const 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>'), '&lt;a href=&quot;x&quot;&gt;&amp;&#39;&lt;/a&gt;');
console.log(' esc (XSS-экранирование): ok');
}
function testFs() {
assert.equal(fs(0), '0 B');
assert.equal(fs(1023), '1023 B');
assert.equal(fs(1024), '1.0 KB');
assert.equal(fs(1048576), '1.0 MB');
console.log(' fs (размер): ok');
}
function testFmtSec() {
assert.equal(fmtSec(0), '0с');
assert.equal(fmtSec(59), '59с');
assert.equal(fmtSec(60), '1м 0с');
assert.equal(fmtSec(125), '2м 5с');
console.log(' fmtSec: ok');
}
function testEstForFile() {
assert.equal(estForFile({ size: 1048576 }, 12), 12); // 1 МБ × 12 = 12с
assert.equal(estForFile(null, 12), 0);
console.log(' estForFile: ok');
}
// ── Прогон ──
const TESTS = [
['decodeZipName', testDecodeZipName],
['zip кириллица', testZipCyrillic],
['вложенный zip', testZipNested],
['не-doc фильтр', testZipNonDoc],
['zip deflate (метод 8)', testZipDeflate],
['не-ZIP бросок', testParseZipNotZip],
['дедуп/суффиксы', testDedup],
['лимиты over', testLimits],
['суффиксы _2/_3', testSuffixes],
['esc XSS', testEsc],
['fs размер', testFs],
['fmtSec', testFmtSec],
['estForFile', testEstForFile],
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('Все фронт-тесты прошли');
+135 -168
View File
@@ -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 (фронт) прошли');
+68 -13
View File
@@ -1,23 +1,78 @@
// putToVm — PUT одного файла на ВМ-буфер (XHR, сырое тело).
// onProgress(pct) — прогресс загрузки. Разрешается при HTTP 2xx.
/**
* putToVm — отправка одного файла в буфер методом HTTP PUT (сырое бинарное тело).
*
* @param {Blob|File} file Файл для отправки.
* @param {string} url Полный целевой URL в буфере.
* @param {Object} options Опции отправки:
* @param {function(number, number, number): void} [options.onProgress] Колбэк прогресса (pct, loaded, total).
* @param {function(XMLHttpRequest): void} [options.onXHR] Колбэк регистрации XHR для прямого контроля.
* @param {AbortSignal} [options.signal] Сигнал прерывания запроса (AbortController.signal).
* @param {number} [options.timeoutMs=300000] Таймаут в миллисекундах (по умолчанию 300с).
* @returns {Promise<void>} Разрешается при ответе 2xx, отклоняется при ошибке или прерывании.
*/
export function putToVm(file, url, options = {}) {
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);
});
}
+187 -46
View 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 };
}
-22
View File
@@ -1,22 +0,0 @@
// decodeZipName — декодирование имени из ZIP: UTF-8-флаг / эвристика (UTF-8 → CP437 → CP866).
export function decodeZipName(bytes, isUtf8) {
if (isUtf8) return new TextDecoder('utf-8').decode(bytes);
// Многие архиваторы пишут имя в UTF-8, но НЕ выставляют UTF-8-флаг (bit 11).
// Сначала строго пробуем UTF-8: если байты — валидный UTF-8 с кириллицей/текстом,
// берём их как есть (иначе декодирование CP437→CP866 превратит их в «╨╣…»-мусор).
try {
const s = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
// Кириллица — точно UTF-8; либо чистый печатаемый текст без управляющих символов.
if (/[\u0400-\u04FF]/.test(s) || !/[^\u0020-\u007e]/.test(s)) return s;
} catch (e) { /* не UTF-8 — legacy (CP437/CP866) */ }
let name;
try { name = new TextDecoder('ibm437').decode(bytes); }
catch (e) { name = new TextDecoder('utf-8').decode(bytes); }
// Кириллица из 1С (CP866) — перекодировать, если имя пришло как CP437-мусор
if (/[^\x00-\x7f]/.test(name)) {
try { name = new TextDecoder('ibm866').decode(bytes); }
catch (e) { /* оставить как есть */ }
}
return name;
}
-11
View File
@@ -1,11 +0,0 @@
// dosToMs — преобразование DOS-даты/времени (ZIP) в миллисекунды (unix epoch).
export function dosToMs(date, time) {
const year = 1980 + ((date >> 9) & 0x7f);
const month = (date >> 5) & 0x0f;
const day = date & 0x1f;
const hour = (time >> 11) & 0x1f;
const min = (time >> 5) & 0x3f;
const sec = (time & 0x1f) * 2;
return new Date(year, month - 1, day, hour, min, sec).getTime();
}
-8
View File
@@ -1,8 +0,0 @@
// inflateRaw — распаковка deflate-raw (метод 8) через нативный DecompressionStream.
export async function inflateRaw(bytes) {
const ds = new DecompressionStream('deflate-raw');
const stream = new Blob([bytes]).stream().pipeThrough(ds);
const ab = await new Response(stream).arrayBuffer();
return new Uint8Array(ab);
}
+120 -17
View File
@@ -1,22 +1,125 @@
// listZipFiles — рекурсивно достать из ZIP только документы (вложенные zip — разворачиваются).
// Построить дерево ZIP с файлами только разрешённых расширений.
import { unzipSync } from 'fflate';
import { rebaseTree } from '../table/rebase_tree.js';
import { parseZip } from './parse_zip.js';
const DEFAULT_LIMITS = {
maxEntries: 1000,
maxTotalBytes: 100 * 1024 * 1024,
maxEntryBytes: 50 * 1024 * 1024,
maxDepth: 20,
};
export async function listZipFiles(file, allowedExt) {
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 });
}
-50
View File
@@ -1,50 +0,0 @@
// parseZip — разбор ZIP: центральный каталог → записи {name, data, dosMs, isDir}.
// Поддерживаются методы 0 (store) и 8 (deflate). Без внешних библиотек.
import { decodeZipName } from './decode_zip_name.js';
import { inflateRaw } from './inflate_raw.js';
import { dosToMs } from './dos_to_ms.js';
export async function parseZip(buf) {
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
let eocd = -1;
for (let i = buf.length - 22; i >= 0; i--) {
if (dv.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
}
if (eocd < 0) throw new Error('Не ZIP');
const cdSize = dv.getUint32(eocd + 12, true);
const cdOffset = dv.getUint32(eocd + 16, true);
const entries = [];
let pos = cdOffset;
const cdEnd = cdOffset + cdSize;
while (pos < cdEnd) {
if (dv.getUint32(pos, true) !== 0x02014b50) break;
const flags = dv.getUint16(pos + 8, true);
const method = dv.getUint16(pos + 10, true);
const modTime = dv.getUint16(pos + 12, true);
const modDate = dv.getUint16(pos + 14, true);
const compSize = dv.getUint32(pos + 20, true);
const nameLen = dv.getUint16(pos + 28, true);
const extraLen = dv.getUint16(pos + 30, true);
const commentLen = dv.getUint16(pos + 32, true);
const localOffset = dv.getUint32(pos + 42, true);
const nameBytes = buf.slice(pos + 46, pos + 46 + nameLen);
const name = decodeZipName(nameBytes, (flags & 0x800) !== 0);
const lhNameLen = dv.getUint16(localOffset + 26, true);
const lhExtraLen = dv.getUint16(localOffset + 28, true);
const dataStart = localOffset + 30 + lhNameLen + lhExtraLen;
const comp = buf.slice(dataStart, dataStart + compSize);
let data;
if (method === 0) data = comp;
else if (method === 8) data = await inflateRaw(comp);
else throw new Error('Метод сжатия ' + method + ' не поддерживается');
entries.push({ name, data, dosMs: dosToMs(modDate, modTime), isDir: name.endsWith('/') });
pos += 46 + nameLen + extraLen + commentLen;
}
return entries;
}