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>
1271 lines
47 KiB
HTML
1271 lines
47 KiB
HTML
<!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> |