Files
RepinoidandCopilot 1b0bcd298a processing: clarify per-file stages and outcomes
Expose extraction, analysis, and replacement progress in the file table; preserve row mapping for duplicates and collapsed archives.

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

1271 lines
47 KiB
HTML
Raw Permalink 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="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='57' height='57' xml:space='preserve' overflow='hidden'><defs><clipPath id='c0'><rect x='652' y='420' width='64' height='75'/></clipPath><clipPath id='c1'><rect x='652' y='420' width='64' height='70'/></clipPath></defs><g clip-path='url(%23c0)' transform='matrix(1.01357 0 0 1.01357 -664.97 -440.306)'><g clip-path='url(%23c1)'><path d='m114.028 43.149v7.659c0 3.785-3.08 6.863-6.866 6.863h-21.825c-3.786 0-6.866-3.08-6.866-6.864v-2.528l0.056.009V15.815l-10.382 2.013 0.004 3.877v28.971c0 9.481 7.713 17.192 17.187 17.192l21.836.132c9.474 0 17.187-7.712 17.187-17.193v-2.654l0.027.004V15.815l-10.382 2.012 0.028 25.322z' fill='%23001c34' fill-rule='evenodd' transform='matrix(1 0 0 1.01337 587.92 420.059)'/></g></g></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: #1f384d;
--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: #eef4fb;
border: 1px solid #c9d9eb;
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 20px;
font-size: 14px;
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-x: auto;
margin-bottom: 6px;
}
#filePicker .fp-table {
width: 100%;
min-width: 740px;
border-collapse: collapse;
table-layout: fixed;
font-size: 14px;
}
#filePicker .fp-table th:nth-child(1) {
width: 47%;
}
#filePicker .fp-table th:nth-child(2) {
width: 15%;
}
#filePicker .fp-table th:nth-child(3) {
width: 220px;
}
#filePicker .fp-table th:nth-child(4) {
width: 8%;
}
#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 td:nth-child(3) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#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: #cfe0fb;
border-color: #cfe0fb;
color: #1f384d;
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;
}
.status.cancelled {
background: #fff4e5;
color: #825500;
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;
let tableFiles = [];
const fileStatuses = new Map();
const groupMembers = new Map();
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) => {
tableFiles = files;
groupMembers.clear();
files.forEach((file) => {
const path = file.path || file.name;
const parts = path.split('/');
for (let depth = 1; depth < parts.length; depth++) {
const groupPath = parts.slice(0, depth).join('/');
if (!groupMembers.has(groupPath)) groupMembers.set(groupPath, []);
groupMembers.get(groupPath).push(file.id);
}
});
if (!busy) {
fileStatuses.clear();
renderFileStatuses();
}
ub.disabled = files.length === 0 || busy;
fc.textContent = formatFileCount(files.length);
}
});
function renderGroupStatus(row) {
const statusCell = row.querySelectorAll('td')[2];
if (!statusCell) return;
const fileIds = groupMembers.get(row.dataset.path) || [];
const statuses = fileIds.map((id) => fileStatuses.get(id)).filter(Boolean);
if (!statuses.length) {
statusCell.textContent = fileIds.length ? `Выбрано: ${formatFileCount(fileIds.length)}` : '';
return;
}
const total = fileIds.length;
const completed = statuses.filter((status) => status.terminal).length;
const successful = statuses.filter((status) => status.outcome === 'success').length;
const duplicates = statuses.filter((status) => status.outcome === 'duplicate').length;
const errors = statuses.filter((status) => status.outcome === 'error').length;
const stopped = statuses.filter((status) => status.outcome === 'cancelled').length;
const uploading = statuses.filter((status) => status.outcome === 'uploaded').length;
const activeStatuses = statuses.filter((status) => status.outcome === 'progress');
const active = activeStatuses.length;
if (completed === total && successful + duplicates === total) {
statusCell.textContent = `Готово: ${formatFileCount(successful)}`;
if (duplicates) statusCell.textContent += `, дубликатов: ${duplicates}`;
} else if (completed === total && uploading === total) {
statusCell.textContent = `Загружено: ${formatFileCount(uploading)}`;
} else if (completed === total) {
statusCell.textContent = `Завершено: ${completed}/${total}`
+ (errors ? `, ошибок: ${errors}` : '')
+ (stopped ? `, остановлено: ${stopped}` : '')
+ (uploading ? `, загружено: ${uploading}` : '');
} else if (active) {
const stage = activeStatuses[0].text.split(' · ')[0];
statusCell.textContent = `${stage}: ${active} в работе, завершено ${completed}/${total}`;
} else if (uploading) {
statusCell.textContent = `Загружено: ${uploading}/${total}`;
} else {
statusCell.textContent = `В очереди: ${total}`;
}
}
function renderFileStatuses() {
const rows = document.querySelectorAll('#filePicker .tree-row');
rows.forEach((row) => {
const statusCell = row.querySelectorAll('td')[2];
if (!statusCell) return;
if (row.classList.contains('tree-file')) {
const status = fileStatuses.get(row.dataset.fileId);
if (status) statusCell.textContent = status.text;
return;
}
if (!row.classList.contains('tree-folder') && !row.classList.contains('tree-zip')) return;
renderGroupStatus(row);
});
}
function setFileStatus(index, text, outcome = 'progress', terminal = false) {
const file = tableFiles[index];
if (!file) return;
const fileId = file.id;
const path = file.path || file.name;
fileStatuses.set(fileId, { text, outcome, terminal });
document.querySelectorAll('#filePicker .tree-row.tree-file').forEach((row) => {
if (row.dataset.fileId === fileId) {
const statusCell = row.querySelectorAll('td')[2];
if (statusCell) statusCell.textContent = text;
}
});
const parts = path.split('/');
for (let depth = 1; depth < parts.length; depth++) {
const groupPath = parts.slice(0, depth).join('/');
document.querySelectorAll('#filePicker .tree-row.tree-folder, #filePicker .tree-row.tree-zip')
.forEach((row) => {
if (row.dataset.path === groupPath) renderGroupStatus(row);
});
}
}
function setEventFileStatus(data, text, outcome = 'progress', terminal = false) {
const indexes = Array.isArray(data.source_idxs) && data.source_idxs.length
? data.source_idxs : [data.idx];
indexes.forEach((index, position) => {
if (position === 0) {
setFileStatus(index, text, outcome, terminal);
return;
}
const duplicateSuccess = terminal && outcome === 'success';
setFileStatus(
index,
duplicateSuccess ? 'Дубликат: результат включён один раз' : `Дубликат: ${text}`,
duplicateSuccess ? 'duplicate' : outcome,
terminal
);
});
}
document.getElementById('filePicker').addEventListener('click', (event) => {
if (event.target.closest('[data-toggle]')) queueMicrotask(renderFileStatuses);
});
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 formatFileCount(count) {
const mod100 = count % 100;
const mod10 = count % 10;
const unit = mod100 >= 11 && mod100 <= 14 ? 'файлов'
: mod10 === 1 ? 'файл'
: mod10 >= 2 && mod10 <= 4 ? 'файла' : 'файлов';
return `${count} ${unit}`;
}
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
.replace('Приём сервисом', 'Сохранение в сессию')
.replace('Завершено. Успешно передано файлов:', 'Загрузка завершена. Передано файлов:');
},
onFileStatus: (k, statusText) => {
const text = statusText
.replace(/^⏳ В буфер\.\.\./, 'Загрузка в буфер')
.replace(/^⏳ (\d+%.*)$/, 'Загрузка в буфер · $1')
.replace(/^⚡ В сессию\.\.\./, 'Сохранение в сессию')
.replace(/^✓ Доставлен$/, '✓ Загружен');
const outcome = /ошибка/i.test(text) ? 'error'
: text === '✓ Загружен' ? 'uploaded' : 'progress';
setFileStatus(k, text, outcome, outcome === 'error' || outcome === 'uploaded');
}
});
} catch (err) {
tableFiles.forEach((file, index) => {
if (!fileStatuses.get(file.id)?.terminal) {
setFileStatus(index, 'Не загружен: ошибка передачи', 'error', true);
}
});
st.className = 'status error';
st.textContent = 'Ошибка загрузки: ' + err.message;
setBusy(false);
cb.style.display = 'none';
ub.disabled = false;
return;
}
if (!res.ok) {
if (res.aborted) {
tableFiles.forEach((_, index) => {
if (!fileStatuses.get(tableFiles[index].id)?.terminal) {
setFileStatus(index, 'Не загружен: передача остановлена', 'cancelled', true);
}
});
st.className = 'status cancelled';
st.textContent = 'Загрузка отменена пользователем.';
} else {
tableFiles.forEach((_, index) => {
if (!fileStatuses.get(tableFiles[index].id)?.terminal) {
setFileStatus(index, 'Не загружен: передача прервана', 'error', true);
}
});
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 = 'Загружено ' + formatFileCount(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 = 'Обработка: подготовка';
cb.style.display = 'inline-block';
fileStatuses.clear();
files.forEach((file) => {
fileStatuses.set(file.id, {
text: 'В очереди',
outcome: 'queued',
terminal: false
});
});
renderFileStatuses();
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 = '';
let currentStage = 'Обработка: подготовка';
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 = currentStage + ' · ' + sec + ' с';
}, 1000);
if (liveRefresh) clearInterval(liveRefresh);
liveRefresh = setInterval(() => {
const sec = ((performance.now() - t0) / 1000).toFixed(1);
liveTimerEl.textContent = sec + ' с';
}, 200);
function updateStage(label, d, detail = '') {
const index = Array.isArray(d.source_idxs) && d.source_idxs.length
? d.source_idxs[0] : d.idx;
const file = files[index];
const fileName = file ? (file.path || file.name) : d.name;
const fileLabel = `файл ${index + 1}/${total}: ${fileName}`;
currentStage = `${label} · ${fileLabel}${detail ? ' · ' + detail : ''}`;
st.textContent = currentStage + ' · ' + Math.round((performance.now() - t0) / 1000) + ' с';
currentLiveFile = fileLabel;
liveFileEl.textContent = currentStage;
}
function setCurrentRow(index) {
const fileId = files[index] && files[index].id;
const rows = document.querySelectorAll('#filePicker .tree-row.tree-file');
rows.forEach((row) => row.classList.remove('row-current'));
const row = Array.from(rows).find((candidate) => candidate.dataset.fileId === fileId);
if (row) row.classList.add('row-current');
}
function markUnfinished(text, outcome) {
files.forEach((file) => {
if (!fileStatuses.get(file.id)?.terminal) {
fileStatuses.set(file.id, { text, outcome, terminal: true });
}
});
renderFileStatuses();
}
function getProcessingCounts() {
const statuses = files.map((file) => fileStatuses.get(file.id));
return {
completed: statuses.filter((status) => status?.outcome === 'success').length,
duplicates: statuses.filter((status) => status?.outcome === 'duplicate').length,
errors: statuses.filter((status) => status?.outcome === 'error').length,
stopped: statuses.filter((status) => status?.outcome === 'cancelled').length
};
}
function showStats(data, totalSec) {
const stats = document.getElementById('statsBlock');
document.getElementById('stTotalTime').textContent = totalSec + ' с';
if (data.llm_sec > 0) {
document.getElementById('stLlmTime').textContent = data.llm_sec + ' с';
document.getElementById('stLlmTokens').textContent = data.tokens > 0 ? data.tokens : '—';
} else {
document.getElementById('stLlmTime').textContent = '—';
document.getElementById('stLlmTokens').textContent = '—';
}
stats.classList.add('show');
}
try {
await new Promise((resolve, reject) => {
activeES = new EventSource('/api/process_stream/' + currentSid);
activeES.addEventListener('extract_start', function (e) {
const d = JSON.parse(e.data);
setEventFileStatus(d, 'Извлечение текста');
setCurrentRow(d.source_idxs?.[0] ?? d.idx);
updateStage('Извлечение текста', d);
});
activeES.addEventListener('extract_done', function (e) {
const d = JSON.parse(e.data);
setEventFileStatus(d, 'Текст извлечён, ожидает анализа');
updateStage('Извлечение текста', d, 'завершено');
});
activeES.addEventListener('extract_failed', function (e) {
const d = JSON.parse(e.data);
setEventFileStatus(d, 'Не извлечён', 'error', true);
updateStage('Извлечение текста', d, 'ошибка');
});
activeES.addEventListener('extract_complete', function () {
document.querySelectorAll('#filePicker .row-current')
.forEach((row) => row.classList.remove('row-current'));
currentStage = 'Анализ текста';
st.textContent = currentStage;
});
activeES.addEventListener('file_start', function (e) {
const d = JSON.parse(e.data);
setEventFileStatus(d, 'Анализ текста');
setCurrentRow(d.source_idxs?.[0] ?? d.idx);
liveEta.textContent = '';
updateStage('Анализ текста', d);
});
activeES.addEventListener('file_chunk', function (e) {
const d = JSON.parse(e.data);
const detail = `фрагмент ${d.chunks_done}/${d.chunks_total}`;
setEventFileStatus(d, `Анализ текста · ${d.chunks_done}/${d.chunks_total}`);
updateStage('Анализ текста', d, detail);
if (typeof d.eta_sec === 'number') liveEta.textContent = 'Осталось около ' + fmtSec(d.eta_sec);
else liveEta.textContent = '';
});
activeES.addEventListener('file_done', function (e) {
const d = JSON.parse(e.data);
setEventFileStatus(d, 'Анализ завершён, ожидает замены');
setCurrentRow(-1);
updateStage('Анализ текста', d, 'завершён');
});
activeES.addEventListener('replace_start', function (e) {
const d = JSON.parse(e.data);
setEventFileStatus(d, 'Замена');
setCurrentRow(d.source_idxs?.[0] ?? d.idx);
updateStage('Замена', d);
});
activeES.addEventListener('replace_failed', function (e) {
const d = JSON.parse(e.data);
setEventFileStatus(d, 'Ошибка замены', 'error', true);
updateStage('Замена', d, 'ошибка');
});
activeES.addEventListener('replace_done', function (e) {
const d = JSON.parse(e.data);
const sec = typeof d.elapsed === 'number' ? d.elapsed.toFixed(1) : '—';
setEventFileStatus(d, `✓ Готово · ${sec} с`, 'success', true);
setCurrentRow(-1);
updateStage('Замена', d, 'завершено');
});
activeES.addEventListener('llm', function (e) {
const d = JSON.parse(e.data);
if (d.active) {
liveLlmTime.textContent = d.elapsed + ' с';
liveLlm.classList.add('show');
if (!currentStage.startsWith('Анализ текста')) {
currentStage = 'Анализ текста';
}
liveFileEl.textContent = currentStage;
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 = currentStage;
}
});
activeES.addEventListener('cancelled', function (e) {
const d = JSON.parse(e.data);
markUnfinished('Остановлено до завершения', 'cancelled');
finishProcUI();
const totalSec = ((performance.now() - t0) / 1000).toFixed(1);
const counts = getProcessingCounts();
st.className = 'status cancelled';
st.textContent = `Остановлено: готово ${counts.completed} из ${files.length}, дубликатов ${counts.duplicates}, ошибок ${counts.errors}, остановлено ${counts.stopped}; сохранено ${formatFileCount(d.saved)} · ${totalSec} с`;
showStats(d, totalSec);
db.classList.add('show');
sessionDone = true;
ub.disabled = true;
nb.style.display = 'inline-block';
resolve();
});
activeES.addEventListener('complete', function (e) {
const d = JSON.parse(e.data);
const totalSec = ((performance.now() - t0) / 1000).toFixed(1);
markUnfinished('Не обработан: статус результата не получен', 'error');
finishProcUI();
const counts = getProcessingCounts();
st.className = counts.errors ? 'status error' : 'status done';
st.textContent = `Готово: ${counts.completed} из ${files.length}, дубликатов: ${counts.duplicates}, ошибок: ${counts.errors} · ${totalSec} с`;
showStats(d, totalSec);
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);
markUnfinished('Не завершён: ошибка обработки', 'error');
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 () {
markUnfinished('Не завершён: потеряно соединение', 'error');
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>