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
+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():
+44 -36
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:
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"],))
deleted += 1
execute("DELETE FROM contracts WHERE id NOT IN (SELECT DISTINCT contract_id FROM supplements)")
contract_ids = set()
keep_ids = set(keep_ids)
with transaction():
for doc in docs:
if doc["id"] in keep_ids:
continue
contract_ids.update(_delete_document(doc["id"]))
deleted += 1
_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
+256 -267
View File
@@ -3,134 +3,48 @@
*
* ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔⛔⛔
*
* v2.0.3: честный счётчик ⏳ соединение... Nс вместо фейкового ↑N%
* Проверено в бою 2026-07-16. Любое изменение = риск сломать загрузку.
*
* КЛЮЧЕВЫЕ ФУНКЦИИ (не трогать):
* - 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 owns selection/tree rendering; this module connects it to the
* Contracts batch, upload sink, and document pipeline.
*
* Состав и обработка документов синхронизируются с 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', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({keep_ids: keepIds})
});
} catch(e) { /* сервер недоступен — не критично */ }
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({batch_id: state.batchId, keep_ids: keepIds})
});
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,14 +222,18 @@ 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';
llmBtn.disabled = false;
@@ -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']);
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('Сервис обработал файл без идентификатора документа');
}
} 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];
if (sinkResult.error === 'duplicate' && sinkResult.doc_id) {
entry.doc_id = sinkResult.doc_id;
entry.uploaded = true;
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 || 'Не удалось обработать файл');
}
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);
}
}
/**
* ⛔ НЕ МЕНЯТЬ ⛔ 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;
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);
}
}
state.files.push({ name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: { kind: 'connecting', elapsed: 0 }, zip_source: null });
state.files[state.files.length - 1]._pendingFile = f;
});
}
function scheduleBatchSync() {
var sync = async function() {
await syncDB();
if (state.files.some(function(file) { return file.doc_id; })) {
showClassifyBtn();
var compareButton = document.getElementById('llmBtn');
if (compareButton) compareButton.style.display = 'flex';
}
};
uploadQueue = uploadQueue.then(sync, sync).catch(showPickerError);
}
function onPickerFilesChange(pickerFiles) {
var previous = new Map(state.files.map(function(file) { return [file.pickerId, file]; }));
var incomingIds = new Set(pickerFiles.map(function(file) { return file.id; }));
var added = [];
var nextFiles = [];
var toQueue = [];
var selectedBytes = 0;
pickerFiles.forEach(function(pickerFile) {
selectedBytes += pickerFile.size;
var entry = previous.get(pickerFile.id);
if (!entry) {
entry = {
pickerId: pickerFile.id,
pickerPath: pickerFile.path,
name: pickerFile.name,
size: pickerFile.size,
lastModified: pickerFile.file.lastModified,
file: pickerFile.file,
zip_source: zipSourceFromPath(pickerFile.path),
status: { kind: 'connecting' }
};
added.push(entry);
}
if (!entry.doc_id && !uploadTasks.has(entry.pickerId)) {
var limitError = entry.size > FILE_PICKER_LIMITS.maxEntryBytes
? 'Файл превышает лимит 50 МБ'
: selectedBytes > FILE_PICKER_LIMITS.maxTotalBytes ? 'Превышен общий лимит 500 МБ' : '';
if (limitError) {
entry.uploadBlocked = true;
entry.status = { kind: 'error', text: limitError };
filePicker.setStatus(entry.pickerId, entry.status);
} else if (entry.uploadBlocked) {
delete entry.uploadBlocked;
entry.status = { kind: 'connecting' };
if (!queuedUploads.has(entry.pickerId)) toQueue.push(entry);
} else if (added.indexOf(entry) !== -1) {
toQueue.push(entry);
}
}
nextFiles.push(entry);
});
var removed = state.files.filter(function(file) { return !incomingIds.has(file.pickerId); });
removed.forEach(function(file) {
var task = uploadTasks.get(file.pickerId);
if (task && task.phase === 'put') task.controller.abort();
});
var changed = added.length > 0 || removed.length > 0 || toQueue.length > 0;
state.files = nextFiles;
if (!state.files.some(function(file) { return file.doc_id; })) state.contractId = null;
if (changed) {
resetStepper('stepUpload');
var classifyButton = document.getElementById('classifyBtn');
if (classifyButton) classifyButton.style.display = 'none';
var compareButton = document.getElementById('llmBtn');
if (compareButton) compareButton.style.display = 'none';
}
render(state);
// Фаза 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' };
entry.uploaded = true;
var pr = resp.parsed;
var elapsed = pr && pr.status === 'parsed' ? '0.0' : null;
applyParseResult(entry, pr, elapsed);
} catch(err) {
entry.status = { kind: 'error', text: err.message };
}
render(state);
}
await finalizeUpload();
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>