From ba9a833ce82aac4758ac46de86cb8762206a3d04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Thu, 25 Jun 2026 08:59:17 +0400 Subject: [PATCH] =?UTF-8?q?v1.0.178:=20=D0=A4=D0=B0=D0=B7=D0=B0=201.4=20?= =?UTF-8?q?=E2=80=94=20=D1=80=D0=B0=D0=B7=D0=B1=D0=B8=D1=82=20fileInput=20?= =?UTF-8?q?handler.=20=D0=A7=D0=B8=D1=81=D1=82=D1=8B=D0=B5=20=D1=84=D1=83?= =?UTF-8?q?=D0=BD=D0=BA=D1=86=D0=B8=D0=B8:=20reconcileSelection,=20applyPa?= =?UTF-8?q?rseResult.=20Async-actions:=20addZipFile,=20addRegularFile.=20?= =?UTF-8?q?=D0=9E=D1=80=D0=BA=D0=B5=D1=81=D1=82=D1=80=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=80:=20onFilesSelected=20(15=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA).=20finalizeUpload.=20app.js=20fileInput=20listener:=204?= =?UTF-8?q?=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/app.js | 151 +------------------------------- deploy/files.js | 228 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+), 147 deletions(-) diff --git a/deploy/app.js b/deploy/app.js index 9325f82..54b2543 100644 --- a/deploy/app.js +++ b/deploy/app.js @@ -71,155 +71,12 @@ function resetStepper(fromId) { // 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); - if (newFiles.length === 0) return; - - fileInput.disabled = true; - - // Удалить из очереди файлы, которых нет в новом выборе - var newNames = new Set(newFiles.map(function(f) { return f.name; })); - state.files = state.files.filter(function(f) { - return newNames.has(f.name); - }); - render(state); - // Разблокировать кнопку классификации — состав файлов изменился - showClassifyBtn(); - // Сбросить прогресс пайплайна - resetStepper('stepUpload'); - - for (var i = 0; i < newFiles.length; i++) { - var f = newFiles[i]; - var isZip = f.name.toLowerCase().endsWith('.zip'); - - if (isZip) { - // ZIP: показать временную строку, распаковать, убрать - var zipEntry = { name: f.name, lastModified: f.lastModified, size: f.size, status: { kind: 'unzipping' } }; - state.files.push(zipEntry); - var zipIdx = state.files.length - 1; - render(state); - - try { - var zipResp = await new Promise(function(resolve, reject) { - var xhr = new XMLHttpRequest(); - xhr.open('POST', UNZIP_URL); - xhr.responseType = 'json'; - xhr.onload = function() { resolve(xhr.response); }; - xhr.onerror = function() { reject(new Error('Сеть')); }; - xhr.ontimeout = function() { reject(new Error('Таймаут')); }; - xhr.timeout = 60000; - xhr.send(f); - }); - var zipData = zipResp; - if (!zipData.ok || !zipData.files) throw new Error('unzip failed'); - - // Убрать строку ZIP из таблицы - state.files.splice(zipIdx, 1); - render(state); - - // Добавить файлы из архива, пропуская дубликаты - for (var zi = 0; zi < zipData.files.length; zi++) { - var zf = zipData.files[zi]; - if (zf.error) continue; // пропускаем ошибки - - // Проверить дубликат по имени - var dup = -1; - for (var dj = 0; dj < state.files.length; dj++) { - if (state.files[dj].name === zf.filename) { dup = dj; break; } - } - if (dup >= 0) continue; // уже есть — не добавляем - - var zEntry = { - name: zf.filename, size: zf.size, - doc_id: zf.doc_id, uploaded: true, - status: { kind: 'parsing' } - }; - state.files.push(zEntry); - var zIdx = state.files.length - 1; - if (!state.contractId && zf.contract_id) state.contractId = zf.contract_id; - render(state); - - // Авто-парсинг: VM Python парсит ВСЁ - var zpr = zf.parsed; - if (zpr && zpr.status === 'parsed') { - state.files[zIdx].parsed = true; - state.files[zIdx].status = { kind: 'parsed', count: zpr.element_count }; - } else if (zpr && zpr.status === 'error') { - state.files[zIdx].status = { kind: 'error', text: zpr.error || 'ошибка парсинга' }; - } else { - state.files[zIdx].status = { kind: 'error', text: 'Неизвестная ошибка' }; - } - render(state); - } - } catch(ze) { - state.files[zipIdx].status = { kind: 'error', text: 'ZIP: ' + ze.message }; - render(state); - } - continue; // ZIP обработан, переходим к следующему файлу - } - - // Обычный файл (не ZIP) - var entry = { name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: { kind: 'uploading', pct: 0 } }; - var existingIdx = -1; - for (var j = 0; j < state.files.length; j++) { - if (state.files[j].name === f.name) { existingIdx = j; break; } - } - var rowIdx; - if (existingIdx >= 0) { - state.files[existingIdx] = entry; - rowIdx = existingIdx; - } else { - state.files.push(entry); - rowIdx = state.files.length - 1; - } - render(state); - - try { - var resp = await uploadFile(f, function(pct) { - state.files[rowIdx].status = { kind: 'uploading', pct: pct }; - render(state); - }); - // Python API returns lowercase keys (doc_id, contract_id, parsed) - if (resp && resp.contract_id) state.contractId = resp.contract_id; - state.files[rowIdx].doc_id = resp.doc_id; - state.files[rowIdx].status = { kind: 'uploaded' }; - state.files[rowIdx].uploaded = true; - - // Авто-парсинг: VM Python парсит ВСЁ (PDF + DOCX + DOC) - var t0 = Date.now(); - var pr = resp.parsed; - if (pr && pr.status === 'parsed') { - var elapsed = ((Date.now() - t0) / 1000).toFixed(1); - state.files[rowIdx].parsed = true; - state.files[rowIdx].parseInfo = pr; - state.files[rowIdx].status = { kind: 'parsed', count: pr.element_count, elapsed: elapsed }; - } else if (pr && pr.status === 'error') { - state.files[rowIdx].status = { kind: 'error', text: pr.error || 'ошибка парсинга' }; - state.files[rowIdx].parseInfo = pr; - } else { - state.files[rowIdx].status = { kind: 'error', text: 'Неизвестная ошибка' }; - } - } catch(err) { - state.files[rowIdx].status = { kind: 'error', text: err.message }; - } - render(state); - } - - await refreshSupps(); - stepDone('stepUpload'); - stepActive('stepClassify'); - - // Синхронизировать БД с таблицей — удалить всё, чего нет в state.files - syncDB(); - - fileInput.value = ''; - fileInput.disabled = false; - // Показать кнопки после авто-парсинга - var llmBtn = document.getElementById('llmBtn'); - llmBtn.style.display = 'flex'; - llmBtn.disabled = false; - showClassifyBtn(); - lucide.createIcons(); + onFilesSelected(newFiles); }); // ── Классификация ────────────────────────────────────────── diff --git a/deploy/files.js b/deploy/files.js index 1c9d41c..d742c25 100644 --- a/deploy/files.js +++ b/deploy/files.js @@ -248,3 +248,231 @@ async function refreshSupps() { } } catch(e) { console.log('refreshSupps error:', e); } } + +/** + * reconcileSelection(files, newFiles) — Чистая функция (Фаза 1). + * + * Согласование состава: оставляет в массиве files только те, + * имена которых есть в newFiles. Остальные удаляются. + * + * Вход: files — текущий state.files (массив) + * newFiles — FileList от (массив 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). + * + * Применяет результат парсинга к файлу. Унифицирует две ветки (ZIP и обычную), + * которые раньше дублировали этот код. + * + * Вход: entry — элемент state.files (мутируется) + * parsed — { status, element_count, error } от бэкенда + * elapsed — время парсинга в секундах (только для обычных файлов) + * Выход: нет (мутирует entry на месте) + */ +function applyParseResult(entry, parsed, elapsed) { + if (parsed && parsed.status === 'parsed') { + entry.parsed = true; + entry.parseInfo = parsed; + entry.status = { kind: 'parsed', count: parsed.element_count }; + if (elapsed) entry.status.elapsed = elapsed; + } else if (parsed && parsed.status === 'error') { + entry.parseInfo = parsed; + entry.status = { kind: 'error', text: parsed.error || 'ошибка парсинга' }; + } else { + entry.status = { kind: 'error', text: 'Неизвестная ошибка' }; + } +} + +/** + * addZipFile(file) — Async-action: обработка ZIP-файла (Фаза 1). + * + * 1. Показывает временную строку «⏳ распаковка...» + * 2. Отправляет ZIP на бэкенд (UNZIP_URL) + * 3. Убирает временную строку + * 4. Добавляет файлы из архива в state.files, пропуская дубликаты + * 5. Для каждого: applyParseResult (результат парсинга от бэкенда) + * + * Мутирует state.files, вызывает render(state) после каждого изменения. + */ +async function addZipFile(file) { + // Показать временную строку для ZIP + var zipEntry = { name: file.name, lastModified: file.lastModified, size: file.size, status: { kind: 'unzipping' } }; + state.files.push(zipEntry); + var zipIdx = state.files.length - 1; + render(state); + + try { + // Отправить ZIP на бэкенд + var zipResp = await new Promise(function(resolve, reject) { + var xhr = new XMLHttpRequest(); + xhr.open('POST', UNZIP_URL); + xhr.responseType = 'json'; + xhr.onload = function() { resolve(xhr.response); }; + xhr.onerror = function() { reject(new Error('Сеть')); }; + xhr.ontimeout = function() { reject(new Error('Таймаут')); }; + xhr.timeout = 60000; + xhr.send(file); + }); + if (!zipResp.ok || !zipResp.files) throw new Error('unzip failed'); + + // Убрать временную строку ZIP + state.files.splice(zipIdx, 1); + render(state); + + // Добавить файлы из архива, пропуская дубликаты по имени + for (var zi = 0; zi < zipResp.files.length; zi++) { + var zf = zipResp.files[zi]; + if (zf.error) continue; // файл с ошибкой — пропускаем + + // Проверить дубликат по имени (защита от повторной загрузки того же архива) + var dup = -1; + for (var dj = 0; dj < state.files.length; dj++) { + if (state.files[dj].name === zf.filename) { dup = dj; break; } + } + if (dup >= 0) continue; // уже есть в очереди — не добавляем + + var zEntry = { + name: zf.filename, size: zf.size, + doc_id: zf.doc_id, uploaded: true, + status: { kind: 'parsing' } + }; + state.files.push(zEntry); + var zIdx = state.files.length - 1; + // Если это первый файл — запомнить contract_id + if (!state.contractId && zf.contract_id) state.contractId = zf.contract_id; + render(state); + + // Применить результат парсинга от бэкенда (clean function) + applyParseResult(state.files[zIdx], zf.parsed); + render(state); + } + } catch(ze) { + // Ошибка распаковки — показать в строке ZIP + state.files[zipIdx].status = { kind: 'error', text: 'ZIP: ' + ze.message }; + render(state); + } +} + +/** + * addRegularFile(file) — Async-action: обработка обычного файла (Фаза 1). + * + * 1. Добавляет/заменяет файл в state.files + * 2. Загружает через XHR (uploadFile) с индикатором прогресса + * 3. После загрузки: проставляет doc_id, contractId + * 4. applyParseResult — применяет результат парсинга + * + * Мутирует state.files, вызывает render(state) после каждого изменения. + */ +async function addRegularFile(file) { + // Создать запись с начальным статусом + var entry = { name: file.name, lastModified: file.lastModified, size: file.size, file: file, status: { kind: 'uploading', pct: 0 } }; + + // Заменить существующий файл с тем же именем или добавить новый + var existingIdx = -1; + for (var j = 0; j < state.files.length; j++) { + if (state.files[j].name === file.name) { existingIdx = j; break; } + } + var rowIdx; + if (existingIdx >= 0) { + state.files[existingIdx] = entry; + rowIdx = existingIdx; + } else { + state.files.push(entry); + rowIdx = state.files.length - 1; + } + render(state); + + try { + // XHR-загрузка с прогрессом + var resp = await uploadFile(file, function(pct) { + state.files[rowIdx].status = { kind: 'uploading', pct: pct }; + render(state); + }); + // Бэкенд возвращает doc_id и contract_id (нижний регистр — Python keys) + if (resp && resp.contract_id) state.contractId = resp.contract_id; + state.files[rowIdx].doc_id = resp.doc_id; + state.files[rowIdx].status = { kind: 'uploaded' }; + state.files[rowIdx].uploaded = true; + + // Авто-парсинг: бэкенд парсит всё (PDF + DOCX + DOC) + var t0 = Date.now(); + var pr = resp.parsed; + var elapsed = pr && pr.status === 'parsed' ? ((Date.now() - t0) / 1000).toFixed(1) : null; + applyParseResult(state.files[rowIdx], pr, elapsed); + } catch(err) { + state.files[rowIdx].status = { kind: 'error', text: err.message }; + } + render(state); +} + +/** + * finalizeUpload() — Завершение цикла загрузки (Фаза 1). + * + * Вызывается ПОСЛЕ обработки всех файлов в onFilesSelected(). + * 1. Обновляет supplement_id/type через refreshSupps() + * 2. Отмечает шаг «Загрузка» как готовый + * 3. Активирует шаг «Классификация» + * 4. Синхронизирует БД + * 5. Показывает кнопки (LLM, классификация) + */ +async function finalizeUpload() { + await refreshSupps(); + stepDone('stepUpload'); + stepActive('stepClassify'); + syncDB(); + + fileInput.value = ''; + fileInput.disabled = false; + + var llmBtn = document.getElementById('llmBtn'); + llmBtn.style.display = 'flex'; + llmBtn.disabled = false; + showClassifyBtn(); + lucide.createIcons(); +} + +/** + * onFilesSelected(newFiles) — Оркестратор загрузки (Фаза 1). + * + * ПАТТЕРН (decoupling-final-plan.md): + * 1. reconcileSelection — согласовать состав (чистая мутация) + * 2. Для каждого файла: addZipFile (ZIP) или addRegularFile (обычный) + * 3. finalizeUpload — завершить цикл + * + * Это ТОНКАЯ функция (~15 строк) — вся логика в шагах 1-3. + * Вызывается из fileInput.addEventListener('change', ...). + */ +async function onFilesSelected(newFiles) { + if (newFiles.length === 0) return; + + fileInput.disabled = true; + + // Шаг 1: согласовать состав — убрать файлы, которых нет в новом выборе + state.files = reconcileSelection(state.files, newFiles); + render(state); + showClassifyBtn(); + resetStepper('stepUpload'); + + // Шаг 2: обработать каждый файл + for (var i = 0; i < newFiles.length; i++) { + var f = newFiles[i]; + if (f.name.toLowerCase().endsWith('.zip')) { + await addZipFile(f); + } else { + await addRegularFile(f); + } + } + + // Шаг 3: финализация + await finalizeUpload(); +}