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
+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();
}