/**
* files.js — Модуль работы с файлами.
*
* ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔⛔⛔
*
* v2.0.2: fetch() + прогресс + таблица сразу
* Проверено в бою 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' | ''
* Выход: безопасная 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 '✗ ' + escHtml(st.text || 'Неизвестная ошибка') + '';
case 'duplicate': return '⤿ дубликат';
case 'warning': return '⚠ ' + escHtml(st.text || 'пустой') + '';
case 'no_format': return '✗ формат не поддерживается';
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
';
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(
'
'
);
}
// Файлы внутри группы (с отступом если из 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(
'
' +
'
' +
(f.doc_id ? '▸' : '') +
escHtml(f.name) + '
' +
'
' + formatDate(f.lastModified) + '
' +
'
' + formatSize(f.size) + '
' +
'
' + statusToHTML(f.status) + '
' +
'
' +
'
' +
'
' +
'
Загрузка...
'
);
}
}
fileTable.innerHTML = 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 += '