Integrate Obfuscator file picker into Contracts
Deploy contracts-flask / validate (push) Canceled after 0s
Deploy contracts-flask / validate (push) Canceled after 0s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+256
-267
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user