Files
drhider/site/templates/index.html
T

690 lines
31 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>DrHider — обфускация документов</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<style>
:root {
--brand-primary: #2563eb;
--brand-gray: #d1d5db;
--brand-grey-light: #f3f4f6;
--bg: #f9fafb;
--card: #ffffff;
--text: #1a1a1a;
--muted: #6b7280;
--red: #ef4444;
--green: #22c55e;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font: 14px system-ui, sans-serif; color: var(--text); background: var(--bg); min-height: 100vh; }
.topbar {
background: var(--card); border-bottom: 1px solid var(--brand-gray);
padding: 0 16px; height: 48px; display: flex; align-items: center; gap: 10px;
}
.topbar img { height: 18px; }
.topbar .title { font-weight: 600; font-size: 14px; }
.topbar .ver { font-weight: 400; color: var(--muted); font-size: 12px; }
.topbar .help { margin-left: auto; font-size: 12px; color: var(--muted); cursor: pointer; text-decoration: underline; }
.topbar .help:hover { color: var(--text); }
.content { max-width: 820px; margin: 20px auto; padding: 0 12px; }
.card {
background: var(--card); border-radius: 12px;
border: 1px solid var(--brand-gray); box-shadow: 0 1px 2px rgba(0,0,0,.04);
}
.card-header {
background: var(--brand-grey-light); padding: 10px 12px;
font-weight: 600; font-size: 14px; border-bottom: 1px solid var(--brand-gray);
}
.card-body { padding: 12px; }
.sub { font-size: 12px; color: var(--muted); margin-bottom: 10px; line-height: 1.5; }
.file-input-wrap { margin-bottom: 10px; }
.file-input-wrap input[type="file"] { width: 100%; font-size: 13px; }
.table-wrap { border: 1px solid var(--brand-gray); border-radius: 8px; overflow: hidden; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th {
background: var(--brand-grey-light); text-transform: uppercase; padding: 6px 8px;
border-right: 1px solid var(--brand-gray); text-align: left;
font-weight: 600; font-size: 10px; color: var(--muted);
}
td {
padding: 5px 8px; border-right: 1px solid var(--brand-gray);
border-bottom: 1px solid var(--brand-gray);
}
tr:hover td { background: rgba(37,99,235,.03); }
.name-cell { max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.num-cell { text-align: right; white-space: nowrap; }
.empty-row td { color: var(--muted); text-align: center; padding: 20px; }
.row-over td { background: rgba(220,38,38,.10) !important; color:#c0392b; }
.row-over .num-cell { color:#c0392b; }
.row-over .name-cell { color:#c0392b; }
.remove-btn {
cursor: pointer; color: #f87171; background: none; border: none;
padding: 2px 4px; font-size: 14px; line-height: 1;
}
.remove-btn:hover { color: var(--red); }
.footer-bar {
margin-top: 10px; display: flex; justify-content: space-between; align-items: center;
font-size: 12px; color: var(--muted);
}
.btn {
height: 32px; border-radius: 6px; padding: 0 12px; font-size: 13px;
font-family: inherit; cursor: pointer; border: 1px solid var(--brand-gray);
background: var(--card); color: var(--text);
display: inline-flex; align-items: center; gap: 5px;
}
.btn-primary { background: var(--brand-primary); border-color: var(--brand-primary); color: #fff; }
.btn-primary:hover { opacity: .9; }
.btn:disabled { opacity: .4; cursor: not-allowed; }
.status { margin-top: 10px; padding: 8px 12px; border-radius: 8px; font-size: 13px; display: none; }
.status.progress { background: #eff6ff; border: 1px solid var(--brand-primary); color: var(--brand-primary); display: block; }
.status.done { background: #f0fdf4; border: 1px solid var(--green); color: #16a34a; display: block; }
.status.error { background: #fef2f2; border: 1px solid var(--red); color: #991b1b; display: block; }
.dl-btns { display: none; gap: 8px; margin-top: 8px; }
.dl-btns.show { display: flex; }
.stats-block {
display: none; margin-top: 12px; padding: 16px;
background: linear-gradient(135deg, #f0fdf4, #ecfdf5);
border: 2px solid var(--green); border-radius: 12px;
}
.stats-block.show { display: block; }
.stats-title { font-size: 16px; font-weight: 700; color: #14532d; margin-bottom: 10px; }
.stats-row { display: flex; flex-wrap: wrap; gap: 10px; }
.stat-item {
flex: 1; min-width: 130px; background: #fff; border: 1px solid #bbf7d0;
border-radius: 10px; padding: 10px 12px; text-align: center;
}
.stat-label { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 4px; }
.stat-value { font-size: 24px; font-weight: 800; color: #166534; }
.live-block {
display: none; margin-top: 12px; padding: 16px;
background: linear-gradient(135deg, #eff6ff, #dbeafe);
border: 2px solid var(--brand-primary); border-radius: 12px;
}
.live-block.show { display: block; }
.live-title { font-size: 18px; font-weight: 800; color: #1e40af; margin-bottom: 6px; }
.live-file { font-size: 15px; color: #1e3a8a; margin-bottom: 10px; word-break: break-word; }
.live-timer { font-size: 40px; font-weight: 800; color: #1d4ed8; line-height: 1.1; }
.live-llm {
display: none; margin-top: 8px; padding: 8px 12px; border-radius: 8px;
background: #fef3c7; border: 1px solid #f59e0b; color: #92400e;
font-size: 18px; font-weight: 700; width: fit-content;
}
.live-llm.show { display: inline-block; }
</style>
</head>
<body>
<div class="topbar">
<img src="/static/logo.svg" alt="Nubes">
<span class="title">DrHider</span>
<span class="ver">v{{ version }}</span>
<span class="help" onclick="document.getElementById('helpModal').style.display='flex'">?</span>
</div>
<div class="content">
<div class="card">
<div class="card-header">Обфускация документов</div>
<div class="card-body">
<p class="sub">
Загрузите документы (.docx, .doc, .pdf, .txt, .md, .zip) — получите ZIP с обезличенными копиями.<br>
CSV с таблицей замен скачивается отдельно.<br>
<span style="color:#c0392b;">Ограничения: суммарно не более 1 ГБ. Файлы сверх суммарного лимита помечаются красным и не участвуют в обфускации.</span>
</p>
<div class="file-input-wrap">
<input type="file" id="fileInput" multiple accept=".doc,.docx,.pdf,.txt,.zip">
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Имя</th>
<th style="width:80px;">Размер</th>
<th style="width:80px;">Статус</th>
<th style="width:32px;"></th>
</tr>
</thead>
<tbody id="fileList">
<tr class="empty-row"><td colspan="4">Нет выбранных файлов</td></tr>
</tbody>
</table>
</div>
<div class="footer-bar">
<span id="fileCount">0 файлов</span>
<button class="btn btn-primary" id="uploadBtn" disabled onclick="uploadFiles()">🛡️ Обфусцировать</button>
</div>
<div class="status" id="status"></div>
<div class="dl-btns" id="dlBtns">
<button class="btn btn-primary" id="dlZip" onclick="downloadZip()">📦 Скачать ZIP</button>
<button class="btn" id="dlCsv" onclick="downloadCsv()">📋 Скачать CSV</button>
</div>
<div class="live-block" id="liveBlock">
<div class="live-title">⏱ Обработка…</div>
<div class="live-file" id="liveFile"></div>
<div class="live-timer" id="liveTimer">0.0 с</div>
<div class="live-llm" id="liveLlm">🤖 ИИ обрабатывает… <span id="liveLlmTime">0.0 с</span></div>
</div>
<div class="stats-block" id="statsBlock">
<div class="stats-title">📊 Итоги обработки</div>
<div class="stats-row">
<div class="stat-item">
<div class="stat-label">Общее время</div>
<div class="stat-value" id="stTotalTime"></div>
</div>
<div class="stat-item">
<div class="stat-label">Из них ИИ</div>
<div class="stat-value" id="stLlmTime"></div>
</div>
<div class="stat-item">
<div class="stat-label">Токены ИИ</div>
<div class="stat-value" id="stLlmTokens"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const fi = document.getElementById('fileInput');
const fl = document.getElementById('fileList');
const fc = document.getElementById('fileCount');
const ub = document.getElementById('uploadBtn');
const st = document.getElementById('status');
const db = document.getElementById('dlBtns');
let sf = [];
let fileMeta = new Map(); // имя -> {size, mtime} для дедупа/суффиксов
let overNames = new Set(); // имена файлов сверх лимита (не участвуют в обфускации)
const MAX_SESSION_BYTES = 1024 * 1024 * 1024; // 1 ГБ суммарно (лимит 100МБ/файл убран)
const VM_UPLOAD_URL = 'https://contracts.kube5s.ru/drhider-upload/'; // ВМ-буфер: PUT больших файлов (шлюз кластера их рвёт)
// Тест-режим: открыть страницу с ?upload-only=1 → только загрузка, без обработки
const TEST_UPLOAD_ONLY = new URLSearchParams(location.search).get('upload-only') === '1';
let currentSid = '';
let activeES = null; // активный EventSource
let activeXHR = null; // активный XHR
function resetAll() {
if (activeES) { activeES.close(); activeES = null; }
if (activeXHR) { activeXHR.abort(); activeXHR = null; }
currentSid = '';
sf = [];
fileMeta = new Map();
overNames = new Set();
fi.value = '';
rr();
st.className = '';
st.textContent = '';
db.classList.remove('show');
document.getElementById('statsBlock').classList.remove('show');
document.getElementById('liveBlock').classList.remove('show');
ub.disabled = false;
}
// При F5 / закрытии вкладки — обрубить всё
window.addEventListener('beforeunload', () => resetAll());
function fs(b) { return b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(1) + ' KB' : (b / 1048576).toFixed(1) + ' MB'; }
function rr() {
if (sf.length === 0) { fl.innerHTML = '<tr class="empty-row"><td colspan="4">Нет выбранных файлов</td></tr>'; }
else {
fl.innerHTML = sf.map((f, i) => {
const over = overNames.has(f.name);
const rowCls = over ? ' class="row-over"' : '';
const stTxt = over ? '<span style="color:#c0392b;">🔥 не учитывается</span>' : '<span style="color:#999;">—</span>';
return '<tr id="row-' + i + '"' + rowCls + '><td class="name-cell">' + f.name + '</td><td class="num-cell">' + fs(f.size) + '</td><td class="num-cell" id="st-' + i + '" style="font-size:12px;">' + stTxt + '</td><td><button class="remove-btn" onclick="rm(' + i + ')">✕</button></td></tr>';
}).join('');
}
const overCount = sf.filter(f => overNames.has(f.name)).length;
const totalSize = sf.reduce((s, f) => s + (overNames.has(f.name) ? 0 : f.size), 0);
const cntMain = sf.length - overCount;
fc.textContent = (overCount ? cntMain + ' учитываются + ' + overCount + ' свыше лимита' : sf.length) + ' файлов · ' + fs(totalSize);
ub.disabled = cntMain === 0;
}
function rm(i) { const nm = sf[i].name; overNames.delete(nm); fileMeta.delete(nm); sf.splice(i, 1); const d = new DataTransfer(); sf.forEach(f => d.items.add(f)); fi.files = d.files; rr(); }
window.addEventListener('load', () => { sf = []; fileMeta = new Map(); fi.value = ''; rr(); /* прогрев upstream-соединения */ fetch('/health').catch(() => {}); });
// ═══════════ Нативная распаковка ZIP (без внешних библиотек) ═══════════
function dosToMs(date, time) {
const year = 1980 + ((date >> 9) & 0x7f);
const month = (date >> 5) & 0x0f;
const day = date & 0x1f;
const hour = (time >> 11) & 0x1f;
const min = (time >> 5) & 0x3f;
const sec = (time & 0x1f) * 2;
return new Date(year, month - 1, day, hour, min, sec).getTime();
}
function decodeZipName(bytes, isUtf8) {
if (isUtf8) return new TextDecoder('utf-8').decode(bytes);
let name;
try { name = new TextDecoder('ibm437').decode(bytes); }
catch (e) { name = new TextDecoder('utf-8').decode(bytes); }
// Кириллица из 1С (CP866) — перекодировать, если имя пришло как CP437-мусор
if (/[^\x00-\x7f]/.test(name)) {
try { name = new TextDecoder('ibm866').decode(bytes); }
catch (e) { /* оставить как есть */ }
}
return name;
}
async function inflateRaw(bytes) {
const ds = new DecompressionStream('deflate-raw');
const stream = new Blob([bytes]).stream().pipeThrough(ds);
const ab = await new Response(stream).arrayBuffer();
return new Uint8Array(ab);
}
async function parseZip(buf) {
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
let eocd = -1;
for (let i = buf.length - 22; i >= 0; i--) {
if (dv.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
}
if (eocd < 0) throw new Error('Не ZIP');
const cdSize = dv.getUint32(eocd + 12, true);
const cdOffset = dv.getUint32(eocd + 16, true);
const entries = [];
let pos = cdOffset;
const cdEnd = cdOffset + cdSize;
while (pos < cdEnd) {
if (dv.getUint32(pos, true) !== 0x02014b50) break;
const flags = dv.getUint16(pos + 8, true);
const method = dv.getUint16(pos + 10, true);
const modTime = dv.getUint16(pos + 12, true);
const modDate = dv.getUint16(pos + 14, true);
const compSize = dv.getUint32(pos + 20, true);
const nameLen = dv.getUint16(pos + 28, true);
const extraLen = dv.getUint16(pos + 30, true);
const commentLen = dv.getUint16(pos + 32, true);
const localOffset = dv.getUint32(pos + 42, true);
const nameBytes = buf.slice(pos + 46, pos + 46 + nameLen);
const name = decodeZipName(nameBytes, (flags & 0x800) !== 0);
const lhNameLen = dv.getUint16(localOffset + 26, true);
const lhExtraLen = dv.getUint16(localOffset + 28, true);
const dataStart = localOffset + 30 + lhNameLen + lhExtraLen;
const comp = buf.slice(dataStart, dataStart + compSize);
let data;
if (method === 0) data = comp;
else if (method === 8) data = await inflateRaw(comp);
else throw new Error('Метод сжатия ' + method + ' не поддерживается');
entries.push({ name, data, dosMs: dosToMs(modDate, modTime), isDir: name.endsWith('/') });
pos += 46 + nameLen + extraLen + commentLen;
}
return entries;
}
async function listZipFiles(file) {
const buf = new Uint8Array(await file.arrayBuffer());
const entries = await parseZip(buf);
const out = [];
// Из ZIP вытаскиваем только документы. Всё прочее (изображения и т.п.) пропускаем.
const allowedExt = ['.pdf', '.doc', '.docx', '.txt', '.md'];
for (const e of entries) {
if (e.isDir) continue;
const low = e.name.toLowerCase();
if (low.endsWith('.zip')) {
const sub = new File([e.data], e.name, { lastModified: e.dosMs });
out.push(...(await listZipFiles(sub)));
} else if (allowedExt.some(ext => low.endsWith(ext))) {
out.push(new File([e.data], e.name, { lastModified: e.dosMs }));
}
// иначе — не документ, пропускаем
}
return out;
}
function addFileWithDedup(file) {
const size = file.size;
const mtime = file.lastModified;
let name = file.name;
if (fileMeta.has(name)) {
const e = fileMeta.get(name);
if (e.size === size) {
// Тот же файл (имя+размер) — дедуп. Дату не сравниваем: файл могли пересохранить
// с тем же содержимым. Оставляем более свежий по дате.
if (mtime > e.mtime) {
e.mtime = mtime;
const idx = sf.findIndex(f => f.name === name);
if (idx >= 0) sf[idx] = new File([file], name, { lastModified: mtime });
}
return;
}
// Имя то же, размер другой — добавить с суффиксом _2, _3...
const dot = name.lastIndexOf('.');
const base = dot > 0 ? name.slice(0, dot) : name;
const ext = dot > 0 ? name.slice(dot) : '';
let n = 2;
while (fileMeta.has(base + '_' + n + ext)) n++;
name = base + '_' + n + ext;
}
fileMeta.set(name, { size, mtime });
// Определяем, превышает ли файл суммарный лимит (не участвует в обфускации)
// Лимит 100МБ/файл убран — учитываются все файлы по размеру.
let over = false;
const sum = sf.reduce((s, f) => s + (overNames.has(f.name) ? 0 : f.size), 0);
if (sum + size > MAX_SESSION_BYTES) over = true;
if (over) {
overNames.add(name);
sf.push(new File([file], name, { lastModified: mtime }));
return;
}
sf.push(new File([file], name, { lastModified: mtime }));
}
fi.addEventListener('change', async () => {
const incoming = Array.from(fi.files);
const hasZip = incoming.some(f => f.name.toLowerCase().endsWith('.zip'));
if (hasZip) {
// Распаковка архивов может занять время — показать индикатор
document.body.style.cursor = 'wait';
st.className = 'status progress';
st.textContent = 'Разбираю архивы…';
}
try {
for (const f of incoming) {
if (f.name.toLowerCase().endsWith('.zip')) {
try {
const nested = await listZipFiles(f);
nested.forEach(x => addFileWithDedup(x));
} catch (err) {
addFileWithDedup(f); // не удалось распаковать — добавить zip как есть
}
} else {
addFileWithDedup(f);
}
}
} finally {
if (hasZip) {
document.body.style.cursor = '';
st.className = '';
st.textContent = '';
}
}
const d = new DataTransfer();
sf.forEach(f => d.items.add(f));
fi.files = d.files;
rr();
});
function ss(idx, h) { const e = document.getElementById('st-' + idx); if (e) e.innerHTML = h; }
async function uploadFiles() {
if (sf.length === 0) return;
// Сохранить список до очистки
const files = sf.slice();
// Только учитываемые файлы идут на загрузку/обфускацию (переборные пропускаем)
const toSend = [];
const sendIdx = []; // sendIdx[k] = индекс в sf для отправленного файла
for (let i = 0; i < files.length; i++) {
if (!overNames.has(files[i].name)) { toSend.push(files[i]); sendIdx.push(i); }
else { ss(i, '<span style="color:#c0392b;">пропущен</span>'); }
}
// Обрубить всё что могло остаться от предыдущего раза
if (activeES) { activeES.close(); activeES = null; }
if (activeXHR) { activeXHR.abort(); activeXHR = null; }
currentSid = '';
st.className = '';
st.textContent = '';
db.classList.remove('show');
document.getElementById('statsBlock').classList.remove('show');
document.getElementById('liveBlock').classList.remove('show');
ub.disabled = true;
const total = toSend.length;
if (total === 0) { st.className = 'status error'; st.textContent = 'Нет файлов для обфускации (все превышают лимит).'; ub.disabled = false; return; }
// Фаза 1: загрузка на ВМ-буфер (PUT напрямую на nginx ВМ, минуя шлюз кластера)
const token = crypto.randomUUID();
const refs = []; // {name, size, url} — для POST /api/upload_refs
let uploadedCount = 0; // сколько файлов реально легло в сессию (для тест-режима)
for (let k = 0; k < total; k++) {
const f = toSend[k];
const i = sendIdx[k]; // индекс в sf для вывода прогресса
const n = k + 1;
st.className = 'status progress';
st.textContent = 'Загрузка на ВМ (этап 1/2) ' + n + '/' + total + ': ' + f.name;
try {
const t0 = performance.now();
const vmUrl = VM_UPLOAD_URL + token + '_' + k; // имя файла в URL не несём (токен-ключ)
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', vmUrl);
xhr.timeout = 300000; // 300с: большие файлы
xhr.upload.onprogress = function(e) {
if (e.lengthComputable) {
const pct = Math.round(e.loaded / e.total * 100);
ss(i, '<span style="color:#2563eb">⏳ ' + pct + '%</span>');
}
};
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
const elapsed = (performance.now() - t0) / 1000;
const speed = f.size / elapsed;
ss(i, '<span style="color:#22c55e">✓ ' + fs(speed) + '/s</span>');
refs.push({ name: f.name, size: f.size, url: vmUrl });
resolve();
} else {
reject(new Error('ВМ: HTTP ' + xhr.status));
}
};
xhr.onerror = function() { reject(new Error('Сеть (ВМ)')); };
xhr.ontimeout = function() { reject(new Error('Таймаут 300с (ВМ)')); };
activeXHR = xhr;
xhr.send(f); // сырое тело файла
});
} catch (err) {
ss(i, '<span style="color:#ef4444">✗</span>');
st.className = 'status error';
st.textContent = 'Ошибка загрузки на ВМ: ' + err.message;
ub.disabled = false;
return;
}
}
// Шаг 1b: один маленький POST во Flask со ссылками на файлы ВМ (<64КБ)
st.className = 'status progress';
st.textContent = 'Передача ссылок в сервис…';
try {
const resp = await fetch('/api/upload_refs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: currentSid, files: refs })
});
const data = await resp.json();
if (!data.ok) throw new Error(data.error || 'HTTP ' + resp.status);
currentSid = data.session;
uploadedCount = data.count || refs.length;
} catch (err) {
st.className = 'status error';
st.textContent = 'Ошибка передачи ссылок: ' + err.message;
ub.disabled = false;
return;
}
// Тест-режим (?upload-only=1): только загрузка, без обработки
if (TEST_UPLOAD_ONLY) {
st.className = 'status done';
st.textContent = '✅ Загружено ' + uploadedCount + ' файлов (тест: обработка пропущена). Сессия: ' + currentSid;
ub.disabled = false;
return;
}
// Фаза 2: обработка (SSE — прогресс по каждому файлу)
// Сброс загрузочных статусов — теперь этап обработки (только отправленные)
for (let k = 0; k < total; k++) ss(sendIdx[k], '<span style="color:#2563eb">⏳</span>');
const fileTimers = {}; // idx -> performance.now()
const fileIntervals = {}; // idx -> setInterval id
st.className = 'status progress';
st.textContent = 'Обработка (этап 2/2)…';
const t0 = performance.now();
const ptimer = setInterval(() => {
const sec = Math.round((performance.now() - t0) / 1000);
st.textContent = 'Обработка (этап 2/2)… ' + sec + 'с';
}, 1000);
// Крупный live-блок «Идёт обработка» с тикающим таймером
const liveBlock = document.getElementById('liveBlock');
const liveTimerEl = document.getElementById('liveTimer');
const liveFileEl = document.getElementById('liveFile');
const liveLlm = document.getElementById('liveLlm');
const liveLlmTime = document.getElementById('liveLlmTime');
let currentLiveFile = ''; // последнее имя файла из start
liveLlm.classList.remove('show');
liveTimerEl.textContent = '0.0 с';
liveFileEl.textContent = 'Подготовка…';
liveBlock.classList.add('show');
const liveRefresh = setInterval(() => {
const sec = ((performance.now() - t0) / 1000).toFixed(1);
liveTimerEl.textContent = sec + ' с';
}, 200);
try {
await new Promise((resolve, reject) => {
activeES = new EventSource('/api/process_stream/' + currentSid);
activeES.addEventListener('start', function(e) {
const d = JSON.parse(e.data);
const idx = sendIdx[d.idx]; // индекс в sf для вывода прогресса
// Таймер тикает ТОЛЬКО у текущего файла; таймеры остальных останавливаем
for (const k in fileIntervals) {
clearInterval(fileIntervals[k]);
delete fileIntervals[k];
}
fileTimers[idx] = performance.now();
fileIntervals[idx] = setInterval(function() {
const elapsed = ((performance.now() - fileTimers[idx]) / 1000).toFixed(1);
ss(idx, '<span style="color:#2563eb">⏳ ' + elapsed + 'с</span>');
}, 200);
// Показываем текущий файл и его размер в live-блоке
const f = sf[idx];
const sz = f ? fs(f.size) : '';
currentLiveFile = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name + (sz ? ' (' + sz + ')' : '');
liveFileEl.textContent = currentLiveFile;
});
activeES.addEventListener('llm', function(e) {
const d = JSON.parse(e.data);
if (d.active) {
liveLlmTime.textContent = d.elapsed + ' с';
liveLlm.classList.add('show');
// LLM анализирует ВСЕ файлы вместе — не показываем имя конкретного
liveFileEl.textContent = '🤖 ИИ анализирует все файлы…';
} else {
liveLlm.classList.remove('show');
if (currentLiveFile) liveFileEl.textContent = currentLiveFile;
}
});
activeES.addEventListener('done', function(e) {
const d = JSON.parse(e.data);
const idx = sendIdx[d.idx];
clearInterval(fileIntervals[idx]);
// Время на КОНКРЕТНЫЙ файл приходит с бэка (extract_text + замена, без общего LLM)
const sec = (typeof d.elapsed === 'number' && d.elapsed > 0)
? d.elapsed.toFixed(1)
: ((performance.now() - (fileTimers[idx] || performance.now())) / 1000).toFixed(1);
ss(idx, '<span style="color:#22c55e">✓ ' + sec + 'с</span>');
});
activeES.addEventListener('complete', function(e) {
activeES.close();
activeES = null;
clearInterval(ptimer);
clearInterval(liveRefresh);
liveBlock.classList.remove('show');
const d = JSON.parse(e.data);
const totalSec = ((performance.now() - t0) / 1000).toFixed(1);
st.className = 'status done';
st.textContent = '✅ Обработано ' + d.total + ' файлов: общее ' + totalSec + 'с, из них ИИ ' + (d.llm_sec > 0 ? d.llm_sec + 'с' : '0с');
// Крупный блок статистики
const sb = document.getElementById('statsBlock');
document.getElementById('stTotalTime').textContent = totalSec + ' с';
if (d.llm_sec > 0) {
document.getElementById('stLlmTime').textContent = d.llm_sec + ' с';
document.getElementById('stLlmTokens').textContent = d.tokens > 0 ? d.tokens : '—';
} else {
document.getElementById('stLlmTime').textContent = '—';
document.getElementById('stLlmTokens').textContent = '—';
}
sb.classList.add('show');
db.classList.add('show');
resolve();
});
activeES.onerror = function() {
activeES.close();
activeES = null;
clearInterval(ptimer);
clearInterval(liveRefresh);
liveBlock.classList.remove('show');
reject(new Error('SSE connection failed'));
};
});
} catch (err) {
clearInterval(ptimer);
clearInterval(liveRefresh);
liveBlock.classList.remove('show');
st.className = 'status error';
st.textContent = 'Ошибка: ' + err.message;
}
ub.disabled = false;
}
function downloadZip() {
if (!currentSid) return;
fetch('/api/download/' + currentSid)
.then(r => {
const disp = r.headers.get('Content-Disposition');
const m = disp && disp.match(/filename="?(.+?)"?$/);
return Promise.all([r.blob(), m ? m[1] : 'drhider.zip']);
})
.then(([blob, fname]) => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = fname;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
});
}
function downloadCsv() {
if (!currentSid) return;
fetch('/api/csv/' + currentSid)
.then(r => {
const disp = r.headers.get('Content-Disposition');
const m = disp && disp.match(/filename="?(.+?)"?$/);
return Promise.all([r.blob(), m ? m[1] : 'mapping.csv']);
})
.then(([blob, fname]) => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = fname;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
});
}
</script>
<div id="helpModal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);z-index:999;justify-content:center;align-items:center" onclick="this.style.display='none'">
<div style="background:var(--card);border-radius:12px;padding:24px;max-width:420px;box-shadow:0 4px 24px rgba(0,0,0,.15)" onclick="event.stopPropagation()">
<h3 style="margin-bottom:12px">DrHider — обфускация документов</h3>
<p style="font-size:13px;color:var(--muted);line-height:1.6">
Загрузите документы (.docx, .doc, .pdf, .txt, .md, .zip) — получите ZIP-архив с обезличенными копиями.
Персональные данные, реквизиты компаний, телефоны, email и другие приватные сведения заменяются на обезличенные коды.
CSV-файл с таблицей соответствия оригинал→замена скачивается отдельно.
</p>
<button class="btn" style="margin-top:12px;width:100%" onclick="document.getElementById('helpModal').style.display='none'">Закрыть</button>
</div>
</div>
</body>
</html>