Files
obfuscator/site/templates/index.html
T
RepinoidandCopilot baa7418cfd ui: align obfuscator with Nubes branding
Update the interface palette, typography and layout to match Nubes while keeping the file workflow unchanged. Bundle Ubuntu locally and bump the app version.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-24 09:19:37 +03:00

1054 lines
37 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>Obfuscator — обфускация документов</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<style>
@font-face {
font-family: 'Ubuntu';
src: url('/static/fonts/Ubuntu.woff2') format('woff2');
font-weight: 300 700;
font-display: swap;
}
:root {
--navy: #001c34;
--brand-primary: #0d6efd;
--brand-gray: #dee2e6;
--brand-grey-light: #f8f9fa;
--bg: #f8f9fa;
--card: #ffffff;
--text: #001c34;
--muted: #6c757d;
--red: #dc3545;
--green: #198754;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font: 14px/1.5 'Ubuntu', system-ui, sans-serif;
color: var(--text);
background: var(--bg);
min-height: 100vh;
}
.topbar {
background: var(--navy);
color: #fff;
padding: 0 24px;
height: 56px;
display: flex;
align-items: center;
gap: 12px;
}
.topbar img {
height: 20px;
filter: brightness(0) invert(1);
}
.topbar .title {
font-weight: 500;
font-size: 15px;
}
.topbar .ver {
color: #a4acb3;
font-size: 12px;
}
.topbar .help {
margin-left: auto;
cursor: pointer;
user-select: none;
padding: 6px 16px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, .4);
color: #fff;
font-size: 13px;
font-weight: 500;
}
.topbar .help:hover {
background: rgba(255, 255, 255, .1);
}
.content {
max-width: 960px;
margin: 40px auto;
padding: 0 24px;
}
.page-title {
font-size: 28px;
font-weight: 500;
color: var(--navy);
line-height: 1.2;
}
.page-sub {
color: var(--muted);
margin: 8px 0 24px;
}
.card {
background: var(--card);
border-radius: 16px;
border: 1px solid var(--brand-gray);
}
.card-body {
padding: 24px;
}
.sub {
font-size: 13px;
color: var(--muted);
line-height: 1.5;
}
.limits {
background: var(--brand-grey-light);
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 20px;
font-size: 13px;
color: var(--muted);
}
.limits ul {
padding-left: 18px;
}
/* FilePicker styling */
#filePicker .file-picker,
#filePicker .fp-table {
font-family: inherit;
color: var(--text);
}
.file-picker .fp-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
#filePicker .fp-toolbar button {
height: 40px;
border-radius: 8px;
padding: 0 18px;
font-size: 14px;
font-family: inherit;
cursor: pointer;
border: 1px solid var(--brand-primary);
background: var(--card);
color: var(--brand-primary);
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 500;
}
#filePicker .fp-toolbar button:hover {
background: #e7f1ff;
}
#filePicker .fp-toolbar button#fp-clear-btn {
color: var(--muted);
border-color: transparent;
margin-left: auto;
}
#filePicker .fp-toolbar button#fp-clear-btn:hover {
background: transparent;
color: var(--red);
}
#filePicker .fp-table-wrap {
border: 1px solid var(--brand-gray);
border-radius: 12px;
overflow: hidden;
margin-bottom: 6px;
}
#filePicker .fp-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
#filePicker .fp-table th {
background: var(--brand-grey-light);
padding: 10px 16px;
border-bottom: 1px solid var(--brand-gray);
text-align: left;
font-weight: 500;
font-size: 13px;
letter-spacing: 0;
text-transform: none;
color: var(--muted);
}
#filePicker .fp-table td {
padding: 10px 16px;
border-bottom: 1px solid var(--brand-gray);
color: var(--text);
}
#filePicker .fp-table tr:last-child td {
border-bottom: 0;
}
#filePicker .fp-table td[colspan] {
color: var(--muted);
text-align: center;
padding: 32px 16px;
font-size: 14px;
}
.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;
}
#filePicker .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: 16px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--muted);
}
.btn {
height: 40px;
border-radius: 8px;
padding: 0 20px;
font-size: 14px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
border: 1px solid var(--brand-gray);
background: var(--card);
color: var(--text);
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-primary {
background: var(--brand-primary);
border-color: var(--brand-primary);
color: #fff;
}
.btn-primary:hover {
background: #0b5ed7;
}
.btn:disabled {
background: #e9ecef;
border-color: #e9ecef;
color: #a4acb3;
cursor: not-allowed;
}
.status {
margin-top: 16px;
padding: 10px 16px;
border-radius: 8px;
font-size: 14px;
display: none;
}
.status.progress {
background: #e7f1ff;
color: #0a58ca;
display: block;
}
.status.done {
background: #e8f5ee;
color: #146c43;
display: block;
}
.status.error {
background: #fdecee;
color: #b02a37;
display: block;
}
.dl-btns {
display: none;
gap: 8px;
margin-top: 12px;
}
.dl-btns.show {
display: flex;
}
.stats-block,
.live-block {
display: none;
margin-top: 16px;
padding: 16px 20px;
background: var(--brand-grey-light);
border: 1px solid var(--brand-gray);
border-radius: 12px;
}
.stats-block.show,
.live-block.show {
display: block;
}
.stats-title,
.live-title {
font-size: 15px;
font-weight: 500;
margin-bottom: 8px;
}
.stats-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.stat-item {
flex: 1;
min-width: 130px;
background: #fff;
border: 1px solid var(--brand-gray);
border-radius: 8px;
padding: 10px 14px;
}
.stat-label {
font-size: 13px;
color: var(--muted);
margin-bottom: 2px;
}
.stat-value {
font-size: 22px;
font-weight: 500;
}
.live-file {
color: var(--muted);
margin-bottom: 8px;
word-break: break-word;
}
.live-timer {
font-size: 32px;
font-weight: 500;
line-height: 1.1;
}
.live-llm {
display: none;
margin-top: 8px;
color: var(--muted);
}
.live-llm.show {
display: inline-block;
}
.live-eta {
margin-top: 8px;
color: var(--muted);
}
</style>
</head>
<body>
<div class="topbar">
<img src="/static/logo.svg" alt="Nubes">
<span class="title">Obfuscator</span>
<span class="ver">v{{ version }}</span>
<span class="help" onclick="document.getElementById('helpModal').style.display='flex'">HELP</span>
</div>
<div class="content">
<h1 class="page-title">Обфускация документов</h1>
<p class="page-sub">Загрузите документы — получите ZIP с обезличенными копиями. CSV с таблицей замен скачивается отдельно.</p>
<div class="card">
<div class="card-body">
<div class="limits">
<ul>
<li>Форматы: .docx, .doc, .pdf, .txt, .md, .zip. Не более 50 МБ на файл и 500 МБ суммарно — файлы сверх лимитов получают статус «пропущен».</li>
<li>Пустые, повреждённые файлы и сканы без текстового слоя получают статус «не извлечён» и не попадают в результат.</li>
<li>Время обработки каждого файла ориентировочное и обновляется по факту.</li>
<li>Фильтр по маскам: положите в корень папки файл <code>obscure.lst</code>. Подробнее — кнопка HELP.</li>
</ul>
</div>
<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://obfuscator-upload.sinefo.ru/obfuscator-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:var(--brand-primary)">…</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:var(--brand-primary)">анализ…</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 = '<span style="color:var(--green)">готов</span>';
}
});
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 = '<span style="color:var(--green)">' + sec + 'с</span>';
}
});
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);
sessionDone = false;
db.classList.remove('show');
document.getElementById('statsBlock').classList.remove('show');
nb.style.display = 'none';
st.className = 'status error';
st.textContent = d.llm_incomplete
? 'Обработка остановлена: LLM не обработал ' + d.llm_errors + ' фрагментов; результат не выдан'
: 'Ошибка обработки: ' + (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 parseFilename(disp) {
if (!disp) return 'obfuscator.zip';
// RFC 5987: filename*=UTF-8''... имеет приоритет над обычным filename=.
const utf8 = disp.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8) {
try { return decodeURIComponent(utf8[1]); } catch (e) { /* повреждённый percent-encoding */ }
}
const plain = disp.match(/filename="?([^";]+)"?/i);
return plain ? plain[1] : 'obfuscator.zip';
}
function downloadZip() {
if (!currentSid) return;
fetch('/api/download/' + currentSid)
.then(r => {
const disp = r.headers.get('Content-Disposition');
return Promise.all([r.blob(), parseFilename(disp)]);
})
.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:640px;max-height:85vh;overflow-y:auto;box-shadow:0 4px 24px rgba(0,0,0,.15);text-align:left"
onclick="event.stopPropagation()">
<h3 style="margin-bottom:12px">Obfuscator — обфускация документов</h3>
<p style="font-size:13px;color:var(--muted);line-height:1.6">
Загрузите документы (.docx, .doc, .pdf, .txt, .md, .zip) — получите ZIP-архив с обезличенными копиями.
Персональные данные, реквизиты компаний, телефоны, email и другие приватные сведения заменяются на обезличенные
коды. CSV-файл с таблицей соответствия оригинал→замена скачивается отдельно.
</p>
<h4 style="margin:18px 0 8px;font-size:14px">Выбор файлов через obscure.lst</h4>
<p style="font-size:13px;color:var(--muted);line-height:1.6">
Чтобы обработать не всю папку, а только нужные файлы, положите в <b>корень</b> выбранной папки файл
<code>obscure.lst</code> со списком масок. Тогда загружаются только файлы, подходящие под правила.
</p>
<ul style="font-size:13px;color:var(--muted);line-height:1.7;padding-left:20px;margin:8px 0">
<li>Файл ищется только в корне папки; имя без учёта регистра: <code>obscure.lst</code>,
<code>OBSCURE.LST</code>.
</li>
<li>Если файла нет — обрабатываются все поддерживаемые документы (как раньше).</li>
<li>Сам <code>obscure.lst</code> не загружается и не обфусцируется.</li>
<li>Другие <code>.lst</code> игнорируются; вложенный <code>subdir/obscure.lst</code> фильтр не включает.</li>
<li>Пустой файл или только комментарии — не загрузится ничего; причина появится в строке статуса.</li>
</ul>
<h4 style="margin:18px 0 8px;font-size:14px">Синтаксис правил</h4>
<ul style="font-size:13px;color:var(--muted);line-height:1.7;padding-left:20px;margin:8px 0">
<li>Одна строка — одно правило; пустые строки игнорируются.</li>
<li><code>#</code> в начале строки — комментарий.</li>
<li><code>!</code> в начале строки — исключение (удаляет совпадения).</li>
<li>Пути задаются относительно выбранной папки; регистр не важен.</li>
<li>Включающие правила собирают файлы, исключения применяются после них.</li>
</ul>
<table style="width:100%;font-size:12px;border-collapse:collapse;margin:8px 0">
<tbody>
<tr>
<td style="padding:4px 8px;border-bottom:1px solid var(--brand-gray);white-space:nowrap"><code>*</code></td>
<td style="padding:4px 8px;border-bottom:1px solid var(--brand-gray);color:var(--muted)">любые символы в
одном сегменте (не <code>/</code>)</td>
</tr>
<tr>
<td style="padding:4px 8px;border-bottom:1px solid var(--brand-gray);white-space:nowrap"><code>?</code></td>
<td style="padding:4px 8px;border-bottom:1px solid var(--brand-gray);color:var(--muted)">ровно один символ,
кроме <code>/</code></td>
</tr>
<tr>
<td style="padding:4px 8px;border-bottom:1px solid var(--brand-gray);white-space:nowrap"><code>**</code>
</td>
<td style="padding:4px 8px;border-bottom:1px solid var(--brand-gray);color:var(--muted)">целый сегмент:
любые подпапки, включая текущую</td>
</tr>
<tr>
<td style="padding:4px 8px;border-bottom:1px solid var(--brand-gray);white-space:nowrap"><code>[a-z]</code>,
<code>[abc]</code>
</td>
<td style="padding:4px 8px;border-bottom:1px solid var(--brand-gray);color:var(--muted)">класс или диапазон
символов</td>
</tr>
<tr>
<td style="padding:4px 8px;border-bottom:1px solid var(--brand-gray);white-space:nowrap"><code>[!a]</code>,
<code>[^a]</code>
</td>
<td style="padding:4px 8px;border-bottom:1px solid var(--brand-gray);color:var(--muted)">любой символ, кроме
указанных</td>
</tr>
<tr>
<td style="padding:4px 8px;border-bottom:1px solid var(--brand-gray);white-space:nowrap">
<code>reports/</code>
</td>
<td style="padding:4px 8px;border-bottom:1px solid var(--brand-gray);color:var(--muted)">то же, что
<code>reports/**</code> — всё поддерево
</td>
</tr>
</tbody>
</table>
<p style="font-size:12px;color:var(--muted);line-height:1.6;margin:6px 0">
<code>*.pdf</code> — только PDF в корне, <code>**/*.pdf</code> — PDF в корне и во всех подпапках.
<code>a**b</code> внутри сегмента работает как <code>a*b</code>.
ZIP фильтруется по имени архива и затем раскрывается целиком.
</p>
<h4 style="margin:18px 0 8px;font-size:14px">Пример obscure.lst</h4>
<pre
style="font-size:12px;background:var(--brand-grey-light);border:1px solid var(--brand-gray);border-radius:8px;padding:10px 12px;overflow-x:auto;margin:8px 0"># Все PDF в корне и во вложенных папках
**/*.pdf
# Договоры в reports, начинающиеся с name
reports/name*.docx
# Исключить черновики и старые архивы
!**/*draft*.*
!archive/**</pre>
<p style="font-size:12px;color:#9a3412;line-height:1.6;margin:6px 0">
Если правило написано с ошибкой (абсолютный путь, <code>..</code>, незакрытый <code>[</code>),
оно пропускается, а номер строки с причиной показывается в строке статуса под таблицей.
</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>