This commit is contained in:
2026-09-11 13:58:06 +03:00
parent dfbc646c51
commit d4dd7be4ef
4 changed files with 484 additions and 126 deletions

View File

@@ -274,6 +274,10 @@
}
.rescan-filemeta:empty { display: none; }
.rescan-filemeta.warn { color: var(--danger); }
.wipe-box { margin-top: 16px; }
.wipe-box input[type=text] { width: 100%; font-family: var(--mono); }
.src-file { cursor: pointer; }
.src-file:hover { color: var(--accent); text-decoration: underline; }
.api-layout { display: grid; grid-template-columns: minmax(280px, 1fr) minmax(320px, 1.1fr); gap: 20px; }
@media (max-width: 900px) { .api-layout { grid-template-columns: 1fr; } }
@@ -360,11 +364,26 @@
<div class="rescan-row">
<label class="stats"><input type="checkbox" id="rescan-force"> force</label>
<button type="button" class="btn-primary" id="rescan-btn" onclick="startRescan()">Старт</button>
<button type="button" class="btn-danger" id="rescan-wipe-btn" hidden
onclick="confirmWipeFile()">Изтрий извлеченията</button>
</div>
<div class="rescan-status" id="rescan-status">Избери файл или папка.</div>
</div>
<div class="rescan-box wipe-box">
<h3>Изтриване на секции</h3>
<p class="stats" style="margin-bottom:10px;line-height:1.45">
Копирай името на файла от таб „Ключови думи“ и го постави тук.
Изтриват се всички секции за този файл (не самият help файл).
</p>
<div class="rescan-row">
<input type="text" id="wipe-filename" placeholder="напр. atra-manual.docx"
oninput="onWipeNameInput()" onkeydown="if(event.key==='Enter'){confirmWipeFile();event.preventDefault();}">
</div>
<div class="rescan-filemeta" id="wipe-filemeta"></div>
<div class="rescan-row">
<button type="button" class="btn-danger" id="wipe-btn" onclick="confirmWipeFile()" disabled>Изтрий всички секции</button>
</div>
<div class="rescan-status" id="wipe-status"></div>
</div>
</div>
<div id="tab-editor" class="panel">
@@ -397,14 +416,14 @@
<div class="search-mode">
<label><input type="radio" name="search-mode" value="all" checked onchange="doSearch()"> всичко</label>
<label><input type="radio" name="search-mode" value="keywords" onchange="doSearch()"> само ключови</label>
<label><input type="radio" name="search-mode" value="text" onchange="doSearch()"> само извлечения</label>
<label><input type="radio" name="search-mode" value="text" onchange="doSearch()"> само секции</label>
</div>
<span class="stats" id="search-stats"></span>
<button class="btn-primary" onclick="addSelectedToGenerator()">Добави избраните → Генератор</button>
</div>
<div class="card-section-label" style="margin-top:0">Ключови думи — клик за филтър</div>
<div class="kw-cloud" id="kw-cloud"></div>
<div class="card-section-label">Извлечения (резултати)</div>
<div class="card-section-label">Секции (резултати)</div>
<div class="results-grid" id="search-results"></div>
</div>
@@ -444,7 +463,7 @@
</div>
<div class="card-section-label">Ключови думи</div>
<div class="card-tags" id="modal-tags"></div>
<div class="card-section-label">Извлечение</div>
<div class="card-section-label">Секция</div>
<div class="modal-body" id="modal-body">Зареждане…</div>
</div>
</div>
@@ -471,9 +490,9 @@
<div class="api-ep"><span class="method">GET</span><code>/api/rescan/{job_id}</code>
<div class="desc">Статус на rescan задача (<code>queued</code> / <code>processing</code> / <code>done</code> / <code>error</code>).</div></div>
<div class="api-ep"><span class="method">GET</span><code>/api/file-extractions?file=…&amp;prefix=RIP</code>
<div class="desc">Брой и кодове на извлеченията за даден файл; <code>ok</code> ако SEC номерацията е уникална и последователна.</div></div>
<div class="desc">Брой и кодове на секциите за даден файл; <code>ok</code> ако SEC номерацията е уникална и последователна.</div></div>
<div class="api-ep"><span class="method post">DELETE</span><code>/api/file-extractions?file=…&amp;prefix=RIP</code>
<div class="desc">Изтрива всички извлечения за файла (не самия help файл). Header опционално: <code>X-Rescan-Token</code>.</div></div>
<div class="desc">Изтрива всички секции за файла (не самия help файл). Header опционално: <code>X-Rescan-Token</code>.</div></div>
<p class="api-note">OpenAPI / Swagger: <a href="/docs" target="_blank" rel="noopener">/docs</a> · ReDoc: <a href="/redoc" target="_blank" rel="noopener">/redoc</a></p>
<p class="api-note">Типичен ERP поток: <code>GET /api/search</code> → избор на <code>code</code> → <code>GET /api/section/{code}</code> за съдържание.</p>
@@ -507,7 +526,7 @@
<div class="modal-backdrop" id="wipe-modal" onclick="if(event.target===this)closeWipeModal()">
<div class="modal" role="dialog" aria-modal="true" style="width:min(460px,100%)">
<div class="modal-head">
<h2>Изтриване на извлечения</h2>
<h2>Изтриване на секции</h2>
<button type="button" class="btn-ghost" onclick="closeWipeModal()">✕</button>
</div>
<div class="modal-body" id="wipe-modal-body"></div>
@@ -566,7 +585,9 @@ const PAGE_PREFIX = {{ (prefix or 'RIP') | tojson }};
let scanMode = null;
let scanFiles = [];
let fileStats = null;
let wipeStats = null;
let wipeTarget = null;
let wipeLookupTimer = null;
function scanExt(name) {
const n = String(name || '');
@@ -578,12 +599,24 @@ function fileBase(p) {
const parts = String(p).replace(/\\/g, '/').split('/');
return parts[parts.length - 1] || '';
}
function sectionCountLabel(n) {
const x = Number(n) || 0;
if (x === 1) return '1 секция';
return x + ' секции';
}
function setScanStatus(text, kind) {
const st = document.getElementById('rescan-status');
st.textContent = text;
st.classList.remove('busy', 'ok', 'error');
if (kind) st.classList.add(kind);
}
function setWipeStatus(text, kind) {
const st = document.getElementById('wipe-status');
if (!st) return;
st.textContent = text || '';
st.classList.remove('busy', 'ok', 'error');
if (kind) st.classList.add(kind);
}
function fileCountBg(n) {
return n === 1 ? '1 файл' : n + ' файла';
}
@@ -592,39 +625,75 @@ function folderNameFrom(files) {
const top = p.replace(/\\/g, '/').split('/')[0];
return top || (files[0] && files[0].name) || 'папка';
}
function statsLine(stats) {
if (!stats) return '';
const n = stats.count || 0;
let line = 'Секции: ' + n;
if (n && stats.first && stats.last) {
line += stats.first === stats.last
? ' · ' + stats.first
: ' · ' + stats.first + ' – ' + stats.last;
}
if (stats.ok === false && stats.issues && stats.issues.length) {
line += ' · ' + stats.issues.join('; ');
}
return line;
}
function renderFileStats() {
const meta = document.getElementById('rescan-filemeta');
const wipeBtn = document.getElementById('rescan-wipe-btn');
const editorExtra = document.getElementById('editor-file-stats');
const n = fileStats ? (fileStats.count || 0) : 0;
const fileSelected = scanMode === 'file' && scanFiles[0];
if (!fileStats || !fileSelected) {
if (meta) meta.textContent = '';
if (wipeBtn) wipeBtn.hidden = true;
if (editorExtra) editorExtra.textContent = '';
if (meta) { meta.textContent = ''; meta.classList.remove('warn'); }
return;
}
let line = 'Извлечения: ' + n;
if (n && fileStats.first && fileStats.last) {
line += fileStats.first === fileStats.last
? ' · ' + fileStats.first
: ' · ' + fileStats.first + ' – ' + fileStats.last;
}
if (meta) {
meta.textContent = line;
if (fileStats.ok === false && fileStats.issues && fileStats.issues.length) {
meta.textContent = line + ' · ' + fileStats.issues.join('; ');
meta.classList.add('warn');
} else {
meta.classList.remove('warn');
meta.textContent = statsLine(fileStats);
meta.classList.toggle('warn', fileStats.ok === false);
}
}
function renderWipeStats() {
const meta = document.getElementById('wipe-filemeta');
const btn = document.getElementById('wipe-btn');
const name = (document.getElementById('wipe-filename') || {}).value;
const base = fileBase((name || '').trim());
if (!base) {
if (meta) { meta.textContent = ''; meta.classList.remove('warn'); }
if (btn) btn.disabled = true;
return;
}
const n = wipeStats ? (wipeStats.count || 0) : 0;
if (meta) {
meta.textContent = wipeStats
? (statsLine(wipeStats) + (wipeStats.file ? ' · ' + wipeStats.file : ''))
: '';
meta.classList.toggle('warn', !!(wipeStats && wipeStats.ok === false));
}
if (btn) btn.disabled = false;
}
async function fetchFileStats(name) {
const base = fileBase(name);
if (!base) return null;
try {
const url = '/api/file-extractions?file=' + encodeURIComponent(base)
+ '&prefix=' + encodeURIComponent(PAGE_PREFIX);
const res = await fetch(url);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = data.detail;
throw new Error(typeof detail === 'string' ? detail : ('HTTP ' + res.status));
}
}
if (wipeBtn) {
wipeBtn.hidden = n === 0;
wipeBtn.disabled = false;
}
if (editorExtra) {
editorExtra.textContent = fileStats.file ? (' · ' + fileStats.file + ': ' + n) : '';
return data;
} catch (e) {
const rows = ALL.filter(r => fileBase(r.source_file).toLowerCase() === base.toLowerCase());
const codes = rows.map(r => r.code).filter(Boolean).sort();
return {
file: base,
count: rows.length,
first: codes[0] || null,
last: codes[codes.length - 1] || null,
ok: true,
issues: [],
};
}
}
async function loadFileStats(name) {
@@ -633,30 +702,26 @@ async function loadFileStats(name) {
renderFileStats();
return;
}
try {
const url = '/api/file-extractions?file=' + encodeURIComponent(name)
+ '&prefix=' + encodeURIComponent(PAGE_PREFIX);
const res = await fetch(url);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = data.detail;
throw new Error(typeof detail === 'string' ? detail : ('HTTP ' + res.status));
}
fileStats = data;
} catch (e) {
const rows = ALL.filter(r => fileBase(r.source_file).toLowerCase() === String(name).toLowerCase());
const codes = rows.map(r => r.code).filter(Boolean).sort();
fileStats = {
file: name,
count: rows.length,
first: codes[0] || null,
last: codes[codes.length - 1] || null,
ok: true,
issues: [],
};
}
fileStats = await fetchFileStats(name);
renderFileStats();
}
function onWipeNameInput() {
if (wipeLookupTimer) clearTimeout(wipeLookupTimer);
const raw = (document.getElementById('wipe-filename').value || '').trim();
const base = fileBase(raw);
if (!base) {
wipeStats = null;
renderWipeStats();
setWipeStatus('');
return;
}
wipeLookupTimer = setTimeout(async () => {
wipeStats = await fetchFileStats(base);
renderWipeStats();
const n = wipeStats ? (wipeStats.count || 0) : 0;
setWipeStatus(n ? ('Намерени ' + sectionCountLabel(n) + '.') : 'Няма секции за този файл.');
}, 250);
}
async function refreshSections() {
const p = {{ (prefix or '') | tojson }};
const url = p ? '/api/sections?prefix=' + encodeURIComponent(p) : '/api/sections';
@@ -739,12 +804,16 @@ function onScanFolderPicked() {
}
function confirmWipeFile() {
const name = scanFiles[0] && scanFiles[0].name;
if (!name) return;
const n = (fileStats && fileStats.count) || 0;
const raw = (document.getElementById('wipe-filename').value || '').trim();
const name = fileBase(raw);
if (!name) {
setWipeStatus('Постави име на файл.', 'error');
return;
}
const n = (wipeStats && wipeStats.count) || 0;
wipeTarget = name;
document.getElementById('wipe-modal-body').textContent =
'Ще се изтрият всички извлечения за «' + name + '» (' + n + ' бр.). Самият help файл не се трие.';
'Ще се изтрият всички секции за «' + name + '» (' + n + ' бр.). Самият help файл не се трие.';
document.getElementById('wipe-modal').classList.add('open');
}
function closeWipeModal() {
@@ -773,12 +842,13 @@ async function doWipeFile() {
document.getElementById('wipe-modal').classList.remove('open');
wipeTarget = null;
await refreshSections();
await loadFileStats(name);
setScanStatus('Изтрити извлечения: ' + (data.deleted || 0) + ' за ' + name + '.', 'ok');
wipeStats = await fetchFileStats(name);
renderWipeStats();
setWipeStatus('Изтрити секции: ' + (data.deleted || 0) + ' за ' + name + '.', 'ok');
toast('Изтрити: ' + (data.deleted || 0));
} catch (e) {
toast(e.message, true);
setScanStatus('Грешка: ' + e.message, 'error');
setWipeStatus('Грешка: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
@@ -788,7 +858,6 @@ async function startRescan() {
const btn = document.getElementById('rescan-btn');
const fileBtn = document.getElementById('rescan-pick-file');
const folderBtn = document.getElementById('rescan-pick-folder');
const wipeBtn = document.getElementById('rescan-wipe-btn');
if (!scanFiles.length) {
setScanStatus(scanMode === 'folder' ? 'Няма подходящи файлове в папката.' : 'Избери файл или папка.', 'error');
return;
@@ -803,7 +872,6 @@ async function startRescan() {
btn.disabled = true;
fileBtn.disabled = true;
folderBtn.disabled = true;
if (wipeBtn) wipeBtn.disabled = true;
setScanStatus('Качване...', 'busy');
try {
const headers = {};
@@ -823,7 +891,6 @@ async function startRescan() {
btn.disabled = false;
fileBtn.disabled = false;
folderBtn.disabled = false;
if (wipeBtn) wipeBtn.disabled = false;
}
}
@@ -840,16 +907,24 @@ async function pollRescan(jobId) {
if (typeof data.sections === 'number') msg += ' Записани: ' + data.sections;
if (files.length === 1) {
const f = files[0];
msg = 'Готово. Извлечения: ' + (f.count || 0);
msg = 'Готово. Секции: ' + (f.count || 0);
if (f.first && f.last) {
msg += f.first === f.last ? ' · ' + f.first : ' · ' + f.first + ' – ' + f.last;
}
if (f.ok === false && f.issues && f.issues.length) msg += ' · ' + f.issues.join('; ');
if (f.keywords_ok === false || (f.ai_errors || 0) > 0) {
msg += ' · ключовите думи са с локален fallback';
}
}
setScanStatus(msg, bad ? 'error' : 'ok');
try {
await refreshSections();
if (scanMode === 'file' && scanFiles[0]) await loadFileStats(scanFiles[0].name);
const wipeName = fileBase((document.getElementById('wipe-filename').value || '').trim());
if (wipeName) {
wipeStats = await fetchFileStats(wipeName);
renderWipeStats();
}
} catch (e) {
setScanStatus((msg || 'Готово.') + ' Презареди страницата.', bad ? 'error' : 'ok');
}
@@ -886,7 +961,9 @@ function renderEditor(rows) {
</div>
</div>
</td>
<td><span class="src-file" title="${esc(r.source_file)}">${esc(shortPath(r.source_file))}</span></td>
<td><span class="src-file" title="Кликни за копиране: ${esc(fileBase(r.source_file))}"
data-file="${esc(fileBase(r.source_file))}"
onclick="copySourceFile(this.dataset.file, event)">${esc(shortPath(r.source_file))}</span></td>
<td style="font-size:11px;color:var(--muted);font-family:var(--mono);white-space:nowrap">${r.updated_at}</td>
</tr>
`).join('');
@@ -904,6 +981,21 @@ function filterEditor() {
renderEditor(filtered);
}
function copySourceFile(name, ev) {
if (ev) ev.stopPropagation();
const base = fileBase(name);
if (!base) return;
const inp = document.getElementById('wipe-filename');
if (inp) inp.value = base;
onWipeNameInput();
const done = () => toast('Копирано: ' + base);
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(base).then(done, done);
} else {
done();
}
}
function onKwChange(inp) {
const code = inp.dataset.code;
inp.classList.add('changed');
@@ -1098,7 +1190,7 @@ function doSearch() {
if (raw && !tokens.length) note = ' · само паразитни думи — показани всички';
document.getElementById('search-stats').textContent =
results.length + ' извлечения' + note;
results.length + ' секции' + note;
document.getElementById('search-results').innerHTML = results.map(r => `
<div class="card ${selected.has(r.code)?'selected':''}" onclick="toggleSelect('${r.code}', this)">
<div class="card-header">
@@ -1112,10 +1204,10 @@ function doSearch() {
<div class="card-tags">
${(r.keywords||'').split(',').filter(k=>k.trim()).map(k=>`<span class="tag">${esc(k.trim())}</span>`).join('') || '<span class="stats">—</span>'}
</div>
<div class="card-section-label">Извлечение</div>
<div class="card-section-label">Секция</div>
<div class="card-text">${r.text_html || esc(r.text||'(няма текст — провери OUTPUT_DIR)')}</div>
<div class="card-actions">
<button type="button" class="btn-ghost" onclick="event.stopPropagation(); openSection('${r.code}')">Цяло извлечение</button>
<button type="button" class="btn-ghost" onclick="event.stopPropagation(); openSection('${r.code}')">Цяла секция</button>
</div>
<div class="card-footer">${esc(shortPath(r.source_file))} &nbsp;·&nbsp; ${r.char_count||0} знака</div>
</div>
@@ -1374,6 +1466,7 @@ try {
doSearch();
renderGenerator();
initApiTab();
renderWipeStats();
} catch (e) {
console.error('init failed', e);
toast('Грешка при зареждане на UI: ' + e.message, true);