Files
drhider/site/templates/index.html
T
2026-09-16 07:42:31 +03:00

958 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;
cursor: pointer;
text-decoration: none;
user-select: none;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 5px 14px;
border-radius: 6px;
background: #1d4ed8;
color: #fff;
font-size: 13px;
font-weight: 700;
line-height: 1;
letter-spacing: .06em;
box-shadow: 0 1px 3px rgba(0, 0, 0, .25);
}
.topbar .help:hover {
background: #1e40af;
color: #fff;
}
.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;
}
/* FilePicker styling */
.file-picker {
font-family: inherit;
color: var(--text);
}
.file-picker .fp-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.file-picker .fp-toolbar button {
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;
font-weight: 500;
}
.file-picker .fp-toolbar button#fp-files-btn {
background: #eff6ff;
border-color: var(--brand-primary);
color: var(--brand-primary);
}
.file-picker .fp-toolbar button#fp-folder-btn {
background: var(--card);
}
.file-picker .fp-toolbar button#fp-clear-btn {
color: var(--muted);
border-color: transparent;
}
.file-picker .fp-toolbar button#fp-clear-btn:hover {
color: var(--red);
}
.file-picker .fp-table-wrap {
border: 1px solid var(--brand-gray);
border-radius: 8px;
overflow: hidden;
margin-bottom: 6px;
}
.file-picker .fp-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.file-picker .fp-table th {
background: var(--brand-grey-light);
text-transform: uppercase;
padding: 6px 8px;
border-right: 1px solid var(--brand-gray);
border-bottom: 1px solid var(--brand-gray);
text-align: left;
font-weight: 600;
font-size: 10px;
color: var(--muted);
}
.file-picker .fp-table td {
padding: 5px 8px;
border-right: 1px solid var(--brand-gray);
border-bottom: 1px solid var(--brand-gray);
}
.file-picker .tree-row:hover td {
background: rgba(37, 99, 235, .03);
}
.file-picker .tree-row.row-current td {
background: rgba(37, 99, 235, .12) !important;
color: #1d4ed8;
font-weight: 600;
}
.file-picker .tree-name {
display: inline-flex;
align-items: center;
gap: 6px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.file-picker .tree-chevron {
display: inline-block;
width: 12px;
color: var(--muted);
font-size: 9px;
}
.file-picker .fp-status {
font-size: 12px;
color: var(--muted);
margin: 4px 0;
}
.file-picker .fp-count {
display: none;
}
.file-picker .remove-btn {
cursor: pointer;
color: #f87171;
background: none;
border: none;
padding: 2px 4px;
font-size: 14px;
line-height: 1;
}
.file-picker .remove-btn:hover {
color: var(--red);
}
body.busy .file-picker button {
pointer-events: none;
opacity: 0.5;
}
body.busy .file-picker .remove-btn {
display: none;
}
.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;
}
.live-eta {
margin-top: 8px;
font-size: 14px;
color: #1d4ed8;
font-weight: 600;
}
</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'">HELP</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;">Ограничения: один файл не более 50 МБ, суммарно не более 500 МБ. Файлы сверх
лимитов помечаются статусом «пропущен» и не участвуют в обфускации. Пустые, повреждённые файлы и сканы без
текстового слоя получают статус «не извлечён» — они тоже не попадают в результат.</span><br>
<span style="color:#7d3c98;">⏱ Время обработки каждого файла — ориентировочное (обновляется по факту).</span>
</p>
<div id="filePicker"></div>
<div class="footer-bar">
<span id="fileCount">0 файлов</span>
<button class="btn" id="cancelBtn" style="display:none" onclick="confirmCancel()">⏹ Прервать</button>
<button class="btn" id="newSessionBtn" style="display:none" onclick="resetAll()">🔄 Новая сессия</button>
<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 class="live-eta" id="liveEta"></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 src="/file-picker/file-picker.iife.js?v={{ version }}"></script>
<script>
const DOC_EXTS = ['.pdf', '.doc', '.docx', '.txt', '.md'];
const ub = document.getElementById('uploadBtn');
const cb = document.getElementById('cancelBtn');
const nb = document.getElementById('newSessionBtn');
const fc = document.getElementById('fileCount');
const st = document.getElementById('status');
const db = document.getElementById('dlBtns');
const MAX_FILE_BYTES = 50 * 1024 * 1024; // 50 МБ на один файл
const MAX_SESSION_BYTES = 500 * 1024 * 1024; // 500 МБ суммарно на сессию
const VM_UPLOAD_URL = 'https://contracts.kube5s.ru/drhider-upload/'; // ВМ-буфер
const TEST_UPLOAD_ONLY = new URLSearchParams(location.search).get('upload-only') === '1';
let currentSid = '';
let activeES = null;
let abortCtrl = null;
let busy = false;
let sessionDone = false;
let ptimer = null, liveRefresh = null;
const picker = FilePicker.initFilePicker({
mount: '#filePicker',
allowedExt: DOC_EXTS,
labels: {
pickFiles: '📄 Выбрать файлы',
pickFolder: '📁 Выбрать папку',
clear: 'Очистить',
empty: 'Нет выбранных файлов',
statusReady: 'готов',
remove: '✕',
columns: { path: 'Имя', size: 'Размер', status: 'Статус' },
},
limits: {
maxEntryBytes: MAX_FILE_BYTES,
maxTotalBytes: MAX_SESSION_BYTES,
maxEntries: 1000,
maxDepth: 20,
},
onChange: (files) => {
ub.disabled = files.length === 0 || busy;
fc.textContent = files.length ? `${files.length} файл(ов)` : '0 файлов';
}
});
function setBusy(b) {
busy = b;
document.body.classList.toggle('busy', b);
}
function fmtSec(s) {
s = Math.max(0, Math.round(s));
return s >= 60 ? Math.floor(s / 60) + 'м ' + (s % 60) + 'с' : s + 'с';
}
function confirmCancel() {
document.getElementById('cancelModal').style.display = 'flex';
}
function doCancel() {
document.getElementById('cancelModal').style.display = 'none';
if (abortCtrl) {
abortCtrl.abort();
}
if (currentSid) {
fetch('/api/cancel/' + currentSid, { method: 'POST' }).catch(() => { });
st.textContent = '⏹ Остановка… завершаем текущий файл';
}
}
function finishProcUI() {
if (activeES) { activeES.close(); activeES = null; }
if (ptimer) { clearInterval(ptimer); ptimer = null; }
if (liveRefresh) { clearInterval(liveRefresh); liveRefresh = null; }
document.getElementById('liveBlock').classList.remove('show');
cb.style.display = 'none';
setBusy(false);
}
function resetAll() {
if (activeES) { activeES.close(); activeES = null; }
if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; }
if (ptimer) { clearInterval(ptimer); ptimer = null; }
if (liveRefresh) { clearInterval(liveRefresh); liveRefresh = null; }
currentSid = '';
sessionDone = false;
setBusy(false);
picker.clear();
nb.style.display = 'none';
cb.style.display = 'none';
st.className = '';
st.textContent = '';
db.classList.remove('show');
document.getElementById('statsBlock').classList.remove('show');
document.getElementById('liveBlock').classList.remove('show');
ub.disabled = true;
}
window.addEventListener('beforeunload', () => resetAll());
async function uploadFiles() {
if (busy) return;
const files = picker.getFiles();
if (!files || files.length === 0) return;
setBusy(true);
sessionDone = false;
nb.style.display = 'none';
cb.style.display = 'inline-block';
ub.disabled = true;
if (activeES) { activeES.close(); activeES = null; }
if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; }
abortCtrl = new AbortController();
currentSid = '';
st.className = 'status progress';
st.textContent = 'Подготовка к загрузке через ВМ-буфер…';
db.classList.remove('show');
document.getElementById('statsBlock').classList.remove('show');
document.getElementById('liveBlock').classList.remove('show');
const total = files.length;
let res;
try {
res = await FilePicker.uploadViaVM(files, {
vmUploadUrl: VM_UPLOAD_URL,
backendUploadUrl: '/api/upload_refs',
signal: abortCtrl.signal,
onStatus: (msg) => {
st.className = 'status progress';
st.textContent = msg;
},
onFileStatus: (k, statusText) => {
const rows = document.querySelectorAll('.tree-row.tree-file');
if (rows[k]) {
const td = rows[k].querySelectorAll('td')[2];
if (td) td.innerHTML = statusText;
}
}
});
} catch (err) {
st.className = 'status error';
st.textContent = 'Ошибка загрузки: ' + err.message;
setBusy(false);
cb.style.display = 'none';
ub.disabled = false;
return;
}
if (!res.ok) {
if (res.aborted) {
st.className = 'status';
st.textContent = '⏹ Загрузка отменена пользователем.';
} else {
st.className = 'status error';
st.textContent = res.error || 'Ошибка загрузки';
}
setBusy(false);
cb.style.display = 'none';
ub.disabled = false;
return;
}
currentSid = res.session;
const uploadedCount = res.count || total;
if (TEST_UPLOAD_ONLY) {
st.className = 'status done';
st.textContent = '✅ Загружено ' + uploadedCount + ' файлов (тест: обработка пропущена). Сессия: ' + currentSid;
setBusy(false);
cb.style.display = 'none';
ub.disabled = false;
nb.style.display = 'inline-block';
return;
}
await startProcessingPhase(total, files);
}
async function startProcessingPhase(total, files) {
st.className = 'status progress';
st.textContent = 'Обработка (этап 2/2)…';
cb.style.display = 'inline-block';
const rows = document.querySelectorAll('.tree-row.tree-file');
rows.forEach(r => {
const td = r.querySelectorAll('td')[2];
if (td) td.innerHTML = '<span style="color:#2563eb">⏳</span>';
});
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');
const liveEta = document.getElementById('liveEta');
let currentLiveFile = '';
liveLlm.classList.remove('show');
liveEta.textContent = '';
liveTimerEl.textContent = '0.0 с';
liveFileEl.textContent = 'Подготовка…';
liveBlock.classList.add('show');
const t0 = performance.now();
if (ptimer) clearInterval(ptimer);
ptimer = setInterval(() => {
const sec = Math.round((performance.now() - t0) / 1000);
st.textContent = 'Обработка (этап 2/2)… ' + sec + 'с';
}, 1000);
if (liveRefresh) clearInterval(liveRefresh);
liveRefresh = setInterval(() => {
const sec = ((performance.now() - t0) / 1000).toFixed(1);
liveTimerEl.textContent = sec + ' с';
}, 200);
let procExtractDone = false;
const fileTimers = {};
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 = d.idx;
const curRows = document.querySelectorAll('.tree-row.tree-file');
if (curRows[idx]) {
curRows.forEach(r => r.classList.remove('row-current'));
curRows[idx].classList.add('row-current');
const td = curRows[idx].querySelectorAll('td')[2];
if (td) td.innerHTML = '<span style="color:#2563eb">⚙️</span>';
}
currentLiveFile = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name;
liveFileEl.textContent = currentLiveFile;
});
activeES.addEventListener('extract_done', function (e) {
procExtractDone = true;
const curRows = document.querySelectorAll('.tree-row.tree-file');
curRows.forEach(r => r.classList.remove('row-current'));
});
activeES.addEventListener('file_start', function (e) {
const d = JSON.parse(e.data);
const idx = d.idx;
fileTimers[idx] = performance.now();
const curRows = document.querySelectorAll('.tree-row.tree-file');
if (curRows[idx]) {
curRows.forEach(r => r.classList.remove('row-current'));
curRows[idx].classList.add('row-current');
const td = curRows[idx].querySelectorAll('td')[2];
if (td) td.innerHTML = '<span style="color:#2563eb">⚙️ анализ…</span>';
}
liveFileEl.textContent = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name;
});
activeES.addEventListener('file_chunk', function (e) {
const d = JSON.parse(e.data);
liveFileEl.textContent = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name +
(typeof d.eta_sec === 'number' ? ' — осталось ~' + fmtSec(d.eta_sec) : '');
});
activeES.addEventListener('file_done', function (e) {
const d = JSON.parse(e.data);
const idx = d.idx;
const curRows = document.querySelectorAll('.tree-row.tree-file');
if (curRows[idx]) {
curRows[idx].classList.remove('row-current');
const td = curRows[idx].querySelectorAll('td')[2];
if (td) td.innerHTML = '✅ готов';
}
});
activeES.addEventListener('llm', function (e) {
const d = JSON.parse(e.data);
if (d.active) {
liveLlmTime.textContent = d.elapsed + ' с';
liveLlm.classList.add('show');
liveFileEl.textContent = '🤖 ИИ анализирует все файлы…';
if (typeof d.eta_sec === 'number' && d.eta_sec >= 0) liveEta.textContent = '⏳ осталось ~' + fmtSec(d.eta_sec);
else liveEta.textContent = '';
} else {
liveLlm.classList.remove('show');
if (currentLiveFile) liveFileEl.textContent = currentLiveFile;
}
});
activeES.addEventListener('done', function (e) {
const d = JSON.parse(e.data);
const idx = d.idx;
const curRows = document.querySelectorAll('.tree-row.tree-file');
if (!procExtractDone) {
if (curRows[idx]) {
const td = curRows[idx].querySelectorAll('td')[2];
if (td) td.innerHTML = '<span style="color:#c0392b;">не извлечён</span>';
}
return;
}
const sec = (typeof d.elapsed === 'number' && d.elapsed > 0)
? d.elapsed.toFixed(1)
: ((performance.now() - (fileTimers[idx] || performance.now())) / 1000).toFixed(1);
if (curRows[idx]) {
const td = curRows[idx].querySelectorAll('td')[2];
if (td) td.innerHTML = '✅ ' + sec + 'с';
}
});
activeES.addEventListener('cancelled', function (e) {
const d = JSON.parse(e.data);
finishProcUI();
const totalSec = ((performance.now() - t0) / 1000).toFixed(1);
st.className = 'status done';
st.textContent = '⏹ Остановлено. Сохранено ' + d.saved + ' из ' + d.total + ' файлов + таблица замен (общее ' + totalSec + 'с)';
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');
sessionDone = true;
ub.disabled = true;
nb.style.display = 'inline-block';
resolve();
});
activeES.addEventListener('complete', function (e) {
finishProcUI();
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');
sessionDone = true;
ub.disabled = true;
nb.style.display = 'inline-block';
resolve();
});
activeES.addEventListener('proc_error', function (e) {
const d = JSON.parse(e.data);
finishProcUI();
setBusy(false);
st.className = 'status error';
st.textContent = 'Ошибка обработки: ' + (d.error || 'неизвестная ошибка');
resolve();
});
activeES.onerror = function () {
finishProcUI();
reject(new Error('SSE connection failed'));
};
});
} catch (err) {
finishProcUI();
setBusy(false);
st.className = 'status error';
st.textContent = 'Ошибка: ' + err.message;
}
if (!sessionDone) 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);
});
}
window.uploadFiles = uploadFiles;
window.resetAll = resetAll;
window.confirmCancel = confirmCancel;
window.doCancel = doCancel;
window.downloadZip = downloadZip;
window.downloadCsv = downloadCsv;
fetch('/health').catch(() => { });
</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>
<div id="cancelModal"
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:440px;box-shadow:0 4px 24px rgba(0,0,0,.15)"
onclick="event.stopPropagation()">
<h3 style="margin-bottom:12px">⏹ Остановить обработку?</h3>
<p style="font-size:13px;color:var(--muted);line-height:1.6" id="cancelModalText">
Будут сохранены: полностью обработанные файлы и таблица замен.
Необработанные файлы в результат не попадут.
Текущий анализ будет доведён до конца.
</p>
<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end">
<button class="btn" onclick="document.getElementById('cancelModal').style.display='none'">Отмена</button>
<button class="btn btn-primary" onclick="doCancel()">Остановить</button>
</div>
</div>
</div>
</body>
</html>