Initial deployable Flask application

This commit is contained in:
Repinoid
2026-10-21 12:00:44 +03:00
commit 2450c0c9a1
48 changed files with 6951 additions and 0 deletions
+958
View File
@@ -0,0 +1,958 @@
<!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://upload.sinefo.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>