/**
* files.js — Модуль работы с файлами (Фаза 1, decoupling-final-plan.md).
*
* ВЫНЕСЕНО из app.js:
* - statusToHTML(st) — чистая: структура → HTML
* - renderFiles(state) — рендер таблицы файлов
* - syncDB() — синхронизация БД с fileQueue
* - toggleClassifyDetail(i) — раскрыть результат классификации
* - uploadFile(file, cb) — XHR-загрузка одного файла (.doc/.docx/.pdf)
* - refreshSupps() — обновить supplement_id для файлов
*
* ЗАВИСИМОСТИ (глобальные, загружаются раньше):
* state.js → state (центральное состояние)
* app_utils.js → escHtml, formatSize, formatDate
* app.js → render(), stepDone, stepActive, resetStepper, showClassifyBtn
*
* ЗАГРУЖАЕТСЯ: после app_utils.js, перед app.js
*/
/**
* statusToHTML(status) — Чистая функция: структура → HTML (Фаза 1).
*
* Вход: { kind, pct?, text?, count?, elapsed? } — ни одного HTML-тега.
* kind = 'uploading' | 'uploaded' | 'unzipping' | 'parsing' | 'parsed' | 'error' | ''
* Выход: безопасная HTML-строка (статусы не содержат пользовательских данных).
*
* ПАТТЕРН (decoupling-final-plan.md): отделяем данные от представления.
* state хранит чистые данные, statusToHTML — чистая функция рендеринга.
*/
function statusToHTML(st) {
if (!st || !st.kind) return '';
switch (st.kind) {
case 'uploading': return '↑ ' + (st.pct || 0) + '%';
case 'uploaded': return '✓ ';
case 'unzipping': return '⏳ распаковка...';
case 'parsing': return '⏳ парсинг...';
case 'parsed': return '✓ ' + (st.count || 0) + ' эл.' + (st.elapsed ? ' (' + st.elapsed + 'с)' : '') + ' ';
case 'error': return '✗ ' + (st.text || 'Неизвестная ошибка') + ' ';
default: return '';
}
}
/**
* renderFiles(state) — Рендер таблицы файлов (бывший renderTable, Фаза 1).
*
* Читает state.files, генерирует HTML для #fileTable.
* Использует statusToHTML() для рендеринга статусов (чистые данные → HTML).
* Вызывает lucide.createIcons() для иконок.
*
* Вход: state (весь объект состояния, но читает только state.files).
* Выход: мутирует DOM (fileTable.innerHTML).
*/
function renderFiles(state) {
if (state.files.length === 0) {
fileTable.innerHTML = '
Нет файлов — выберите .docx / .pdf ';
} else {
fileTable.innerHTML = state.files.map(function(f, i) {
var rows = '' +
'' +
(f.doc_id ? '▸ ' : '') +
escHtml(f.name) + ' ' +
'' + formatDate(f.lastModified) + ' ' +
'' + formatSize(f.size) + ' ' +
'' + statusToHTML(f.status) + ' ' +
' ' +
' ' +
' ' +
'Загрузка... ';
return rows;
}).join('');
}
lucide.createIcons();
}
/**
* syncDB() — Синхронизация БД с текущим состоянием файлов.
*
* Отправляет на бэкенд список doc_id, которые должны остаться в БД.
* Всё, чего нет в state.files, будет удалено из БД (cascade).
* Вызывается после каждого изменения состава файлов (removeFile, upload).
*/
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) { /* сервер недоступен — не критично */ }
}
/**
* toggleClassifyDetail(i) — Раскрыть/скрыть результат классификации под строкой файла.
*
* Вызывается по клику на имя файла в таблице.
* Загружает данные с бэкенда (/api/documents/:id) и показывает:
* - Тип, номер, родительский номер, дату, контрагента
* - Текст отправленный в LLM (📤)
* - Сырой ответ LLM (📥)
* - Распарсенные элементы JSON (📄)
*/
window.toggleClassifyDetail = async function(i) {
var detailRow = document.getElementById('detail_' + i);
var expandIcon = document.getElementById('expand_' + i);
if (!detailRow) return;
if (detailRow.style.display !== 'none') {
detailRow.style.display = 'none';
if (expandIcon) expandIcon.textContent = '▸';
return;
}
var f = state.files[i];
if (!f || !f.doc_id) return;
detailRow.style.display = '';
if (expandIcon) expandIcon.textContent = '▾';
try {
var resp = await fetch(VM_API + '/api/documents/' + f.doc_id);
var qData = await resp.json();
if (!qData.ok) { detailRow.cells[0].innerHTML = 'Ошибка загрузки '; return; }
var html = '';
if (qData.classify_status === 'classified') {
html += '🏷️ Результат классификации
';
html += '';
if (qData.doc_type) html += 'Тип: ' + escHtml(qData.doc_type) + ' ';
if (qData.own_number) html += 'Номер: ' + escHtml(qData.own_number) + ' ';
if (qData.parent_number) html += 'Родительский: ' + escHtml(qData.parent_number) + ' ';
if (qData.doc_date) html += 'Дата: ' + escHtml(qData.doc_date) + ' ';
if (qData.counterparty) html += 'Контрагент: ' + escHtml(qData.counterparty) + ' ';
html += '
';
if (qData.classify_input) {
html += '📤 Текст отправленный в LLM ';
html += '' + escHtml(qData.classify_input) + ' ';
html += ' ';
}
if (qData.classify_raw) {
html += '📥 Сырой ответ LLM ';
html += '' + escHtml(qData.classify_raw) + ' ';
html += ' ';
}
if (qData.elements_json) {
var ej = qData.elements_json;
if (typeof ej === 'object' && ej.Value) ej = ej.Value;
var ejStr = typeof ej === 'string' ? ej : JSON.stringify(ej, null, 2);
html += '📄 Распарсенные элементы (JSON) ';
html += '' + escHtml(ejStr.substring(0, 5000)) + (ejStr.length > 5000 ? '\n... (обрезано)' : '') + ' ';
html += ' ';
}
} else if (qData.classify_status === 'failed') {
html += '✗ Ошибка классификации: ' + escHtml(qData.error_message || '?') + ' ';
} else {
html += 'Ещё не классифицирован ';
}
detailRow.cells[0].innerHTML = html;
} catch(e) {
detailRow.cells[0].innerHTML = 'Ошибка: ' + escHtml(e.message) + ' ';
}
};
/**
* uploadFile(file, onProgress) — XHR-загрузка одного файла на бэкенд.
*
* Особенности:
* - .doc (не .docx!) конвертируется через CONVERT_URL перед загрузкой
* - onProgress(pct) — callback с процентом загрузки (0-100)
* - Возвращает Promise<ответ API> с полями doc_id, contract_id, parsed
* - Таймаут 180с (большие PDF)
*/
function uploadFile(file, onProgress) {
return new Promise(function(resolve, reject) {
// .doc → конвертация в docx (старый формат Word)
var isDoc = file.name.toLowerCase().endsWith('.doc') && !file.name.toLowerCase().endsWith('.docx');
var uploadFile = file;
var uploadName = file.name;
function doUpload() {
var xhr = new XMLHttpRequest();
var fd = new FormData();
fd.append('files', uploadFile, uploadName);
if (state.contractId) fd.append('contract_id', state.contractId);
fd.append('batch_id', state.batchId);
xhr.open('POST', UPLOAD_URL);
xhr.upload.onprogress = function(e) {
if (e.lengthComputable && onProgress) onProgress(Math.round(e.loaded / e.total * 100));
};
xhr.onload = function() {
try {
var r = JSON.parse(xhr.responseText);
if (r.ok) resolve(r);
else reject(new Error(r.error || 'Неизвестная ошибка'));
} catch(e) { reject(new Error('Некорректный ответ')); }
};
xhr.onerror = function() { reject(new Error('Сеть')); };
xhr.ontimeout = function() { reject(new Error('Таймаут')); };
xhr.timeout = 180000;
xhr.send(fd);
}
if (isDoc) {
var xhr = new XMLHttpRequest();
xhr.open('POST', CONVERT_URL);
xhr.responseType = 'blob';
xhr.onload = function() {
if (xhr.status === 200 && xhr.response.size > 100) {
uploadFile = xhr.response;
uploadName = file.name.replace(/\.doc$/i, '.docx');
doUpload();
} else {
reject(new Error('Конвертация .doc'));
}
};
xhr.onerror = function() { reject(new Error('Конвертер')); };
xhr.send(file);
} else {
doUpload();
}
});
}
/**
* refreshSupps() — Обновить supplement_id/type для файлов в state.files.
*
* Запрашивает с бэкенда список supplement-записей для текущего contractId,
* сопоставляет их с файлами по doc_id → document_id и проставляет supp_id, supp_type.
* Вызывается после каждого upload и после apply-groups.
*/
async function refreshSupps() {
if (!state.contractId) { console.log('refreshSupps: no state.contractId'); return; }
try {
var resp = await fetch(VM_API + '/api/supplements?contract_id=' + state.contractId);
var data = await resp.json();
console.log('refreshSupps: data=', data);
if (data.ok && data.supplements) {
data.supplements.forEach(function(supp) {
console.log('refreshSupps: supp=', supp);
for (var i = 0; i < state.files.length; i++) {
if (state.files[i].doc_id === supp.document_id) {
state.files[i].supp_id = supp.id;
state.files[i].supp_type = supp.type;
}
}
});
render(state);
}
} 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();
}