Give each help file unique sequential extraction codes, show the count, and allow wiping all extractions for a file while testing.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -268,6 +268,12 @@
|
||||
.rescan-status.busy { color: var(--accent); }
|
||||
.rescan-status.ok { color: var(--accent2); }
|
||||
.rescan-status.error { color: var(--danger); }
|
||||
.rescan-filemeta {
|
||||
font-family: var(--mono); font-size: 12px; color: var(--muted);
|
||||
margin-bottom: 12px; line-height: 1.45;
|
||||
}
|
||||
.rescan-filemeta:empty { display: none; }
|
||||
.rescan-filemeta.warn { color: var(--danger); }
|
||||
|
||||
.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; } }
|
||||
@@ -350,9 +356,12 @@
|
||||
webkitdirectory directory multiple
|
||||
onchange="onScanFolderPicked()">
|
||||
<div class="rescan-selection" id="rescan-selection"></div>
|
||||
<div class="rescan-filemeta" id="rescan-filemeta"></div>
|
||||
<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>
|
||||
@@ -362,6 +371,7 @@
|
||||
<div class="toolbar">
|
||||
<input type="text" class="search-box" id="editor-search" placeholder="Филтрирай по код, заглавие, ключова дума..." oninput="filterEditor()">
|
||||
<span class="stats" id="editor-stats"></span>
|
||||
<span class="stats" id="editor-file-stats"></span>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table id="editor-table">
|
||||
@@ -460,6 +470,10 @@
|
||||
<div class="desc">multipart: <code>file</code> (един или повече: zip/docx/…), <code>prefix</code>, <code>force</code>. Header опционално: <code>X-Rescan-Token</code>.</div></div>
|
||||
<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=…&prefix=RIP</code>
|
||||
<div class="desc">Брой и кодове на извлеченията за даден файл; <code>ok</code> ако SEC номерацията е уникална и последователна.</div></div>
|
||||
<div class="api-ep"><span class="method post">DELETE</span><code>/api/file-extractions?file=…&prefix=RIP</code>
|
||||
<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>
|
||||
@@ -490,6 +504,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<button type="button" class="btn-ghost" onclick="closeWipeModal()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body" id="wipe-modal-body"></div>
|
||||
<div class="rescan-row" style="margin:16px 0 0">
|
||||
<button type="button" class="btn-danger" id="wipe-confirm-btn" onclick="doWipeFile()">Изтрий</button>
|
||||
<button type="button" class="btn-ghost" onclick="closeWipeModal()">Отказ</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast"></div>
|
||||
|
||||
<script type="application/json" id="sections-data">{{ sections_json | safe }}</script>
|
||||
@@ -534,14 +562,22 @@ function switchTab(name) {
|
||||
}
|
||||
|
||||
const SCAN_EXT = ['.zip', '.docx', '.doc', '.html', '.htm', '.pdf', '.txt'];
|
||||
const PAGE_PREFIX = {{ (prefix or 'RIP') | tojson }};
|
||||
let scanMode = null;
|
||||
let scanFiles = [];
|
||||
let fileStats = null;
|
||||
let wipeTarget = null;
|
||||
|
||||
function scanExt(name) {
|
||||
const n = String(name || '');
|
||||
const i = n.lastIndexOf('.');
|
||||
return i >= 0 ? n.slice(i).toLowerCase() : '';
|
||||
}
|
||||
function fileBase(p) {
|
||||
if (!p) return '';
|
||||
const parts = String(p).replace(/\\/g, '/').split('/');
|
||||
return parts[parts.length - 1] || '';
|
||||
}
|
||||
function setScanStatus(text, kind) {
|
||||
const st = document.getElementById('rescan-status');
|
||||
st.textContent = text;
|
||||
@@ -556,6 +592,84 @@ function folderNameFrom(files) {
|
||||
const top = p.replace(/\\/g, '/').split('/')[0];
|
||||
return top || (files[0] && files[0].name) || 'папка';
|
||||
}
|
||||
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 = '';
|
||||
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');
|
||||
}
|
||||
}
|
||||
if (wipeBtn) {
|
||||
wipeBtn.hidden = n === 0;
|
||||
wipeBtn.disabled = false;
|
||||
}
|
||||
if (editorExtra) {
|
||||
editorExtra.textContent = fileStats.file ? (' · ' + fileStats.file + ': ' + n) : '';
|
||||
}
|
||||
}
|
||||
async function loadFileStats(name) {
|
||||
if (!name) {
|
||||
fileStats = null;
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
renderFileStats();
|
||||
}
|
||||
async function refreshSections() {
|
||||
const p = {{ (prefix or '') | tojson }};
|
||||
const url = p ? '/api/sections?prefix=' + encodeURIComponent(p) : '/api/sections';
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
ALL = await res.json();
|
||||
document.getElementById('total-count').textContent = ALL.length + ' секции';
|
||||
const q = document.getElementById('editor-search');
|
||||
if (q && q.value) filterEditor();
|
||||
else renderEditor(ALL);
|
||||
renderKwCloud();
|
||||
doSearch();
|
||||
}
|
||||
function setScanSelection(mode, files) {
|
||||
scanMode = mode;
|
||||
scanFiles = files;
|
||||
@@ -569,18 +683,22 @@ function setScanSelection(mode, files) {
|
||||
if (!mode) {
|
||||
sel.textContent = '';
|
||||
setScanStatus('Избери файл или папка.');
|
||||
loadFileStats('');
|
||||
return;
|
||||
}
|
||||
if (mode === 'file') {
|
||||
if (!files[0]) {
|
||||
sel.textContent = '';
|
||||
setScanStatus('Избери файл.', 'error');
|
||||
loadFileStats('');
|
||||
return;
|
||||
}
|
||||
sel.textContent = 'Файл: ' + files[0].name;
|
||||
setScanStatus('Готово за старт.');
|
||||
loadFileStats(files[0].name);
|
||||
return;
|
||||
}
|
||||
loadFileStats('');
|
||||
if (!files.length) {
|
||||
sel.textContent = 'Папка: няма подходящи файлове';
|
||||
setScanStatus('Няма .zip, .docx, .html, .pdf или .txt в папката.', 'error');
|
||||
@@ -620,17 +738,64 @@ function onScanFolderPicked() {
|
||||
setScanSelection('folder', files);
|
||||
}
|
||||
|
||||
function confirmWipeFile() {
|
||||
const name = scanFiles[0] && scanFiles[0].name;
|
||||
if (!name) return;
|
||||
const n = (fileStats && fileStats.count) || 0;
|
||||
wipeTarget = name;
|
||||
document.getElementById('wipe-modal-body').textContent =
|
||||
'Ще се изтрият всички извлечения за «' + name + '» (' + n + ' бр.). Самият help файл не се трие.';
|
||||
document.getElementById('wipe-modal').classList.add('open');
|
||||
}
|
||||
function closeWipeModal() {
|
||||
const modal = document.getElementById('wipe-modal');
|
||||
if (modal) modal.classList.remove('open');
|
||||
wipeTarget = null;
|
||||
}
|
||||
async function doWipeFile() {
|
||||
const name = wipeTarget;
|
||||
if (!name) return;
|
||||
const btn = document.getElementById('wipe-confirm-btn');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const token = localStorage.getItem('RESCAN_TOKEN') || '';
|
||||
const headers = {};
|
||||
if (token) headers['X-Rescan-Token'] = token;
|
||||
let url = '/api/file-extractions?file=' + encodeURIComponent(name)
|
||||
+ '&prefix=' + encodeURIComponent(PAGE_PREFIX);
|
||||
if (token) url += '&token=' + encodeURIComponent(token);
|
||||
const res = await fetch(url, { method: 'DELETE', headers });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const detail = data.detail;
|
||||
throw new Error(typeof detail === 'string' ? detail : (data.message || ('HTTP ' + res.status)));
|
||||
}
|
||||
document.getElementById('wipe-modal').classList.remove('open');
|
||||
wipeTarget = null;
|
||||
await refreshSections();
|
||||
await loadFileStats(name);
|
||||
setScanStatus('Изтрити извлечения: ' + (data.deleted || 0) + ' за ' + name + '.', 'ok');
|
||||
toast('Изтрити: ' + (data.deleted || 0));
|
||||
} catch (e) {
|
||||
toast(e.message, true);
|
||||
setScanStatus('Грешка: ' + e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const fd = new FormData();
|
||||
scanFiles.forEach(f => fd.append('file', f));
|
||||
fd.append('prefix', {{ (prefix or 'RIP') | tojson }});
|
||||
fd.append('prefix', PAGE_PREFIX);
|
||||
fd.append('force', document.getElementById('rescan-force').checked ? 'true' : 'false');
|
||||
const token = localStorage.getItem('RESCAN_TOKEN') || '';
|
||||
if (token) fd.append('token', token);
|
||||
@@ -638,6 +803,7 @@ async function startRescan() {
|
||||
btn.disabled = true;
|
||||
fileBtn.disabled = true;
|
||||
folderBtn.disabled = true;
|
||||
if (wipeBtn) wipeBtn.disabled = true;
|
||||
setScanStatus('Качване...', 'busy');
|
||||
try {
|
||||
const headers = {};
|
||||
@@ -657,6 +823,7 @@ async function startRescan() {
|
||||
btn.disabled = false;
|
||||
fileBtn.disabled = false;
|
||||
folderBtn.disabled = false;
|
||||
if (wipeBtn) wipeBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -667,8 +834,25 @@ async function pollRescan(jobId) {
|
||||
const data = await res.json();
|
||||
const line = data.status + (data.message ? ' — ' + data.message : '');
|
||||
if (data.status === 'done') {
|
||||
setScanStatus('Готово. Презареждане...', 'ok');
|
||||
location.reload();
|
||||
const files = data.files || [];
|
||||
const bad = files.some(f => f && f.ok === false);
|
||||
let msg = 'Готово.';
|
||||
if (typeof data.sections === 'number') msg += ' Записани: ' + data.sections;
|
||||
if (files.length === 1) {
|
||||
const f = files[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('; ');
|
||||
}
|
||||
setScanStatus(msg, bad ? 'error' : 'ok');
|
||||
try {
|
||||
await refreshSections();
|
||||
if (scanMode === 'file' && scanFiles[0]) await loadFileStats(scanFiles[0].name);
|
||||
} catch (e) {
|
||||
setScanStatus((msg || 'Готово.') + ' Презареди страницата.', bad ? 'error' : 'ok');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data.status === 'error') {
|
||||
@@ -964,7 +1148,10 @@ function closeSection() {
|
||||
document.getElementById('section-modal').classList.remove('open');
|
||||
}
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeSection();
|
||||
if (e.key === 'Escape') {
|
||||
closeWipeModal();
|
||||
closeSection();
|
||||
}
|
||||
});
|
||||
|
||||
function toggleSelect(code, el) {
|
||||
|
||||
Reference in New Issue
Block a user