.
This commit is contained in:
@@ -392,7 +392,7 @@ def api_file_extractions(
|
||||
file: str = Query(..., min_length=1),
|
||||
prefix: str = Query("RIP"),
|
||||
):
|
||||
"""Брой и номерация на извлеченията за даден help файл."""
|
||||
"""Брой и номерация на секциите за даден help файл."""
|
||||
name = _safe_help_filename(file)
|
||||
conn = db_conn()
|
||||
try:
|
||||
@@ -430,7 +430,7 @@ def api_delete_file_extractions(
|
||||
x_rescan_token: Optional[str] = Header(None, alias="X-Rescan-Token"),
|
||||
token: Optional[str] = Query(None),
|
||||
):
|
||||
"""Изтрива всички извлечения за файла. Самият help файл не се пипа."""
|
||||
"""Изтрива всички секции за файла. Самият help файл не се пипа."""
|
||||
rescan_mod._check_token(x_rescan_token or token)
|
||||
name = _safe_help_filename(file)
|
||||
conn = db_conn()
|
||||
|
||||
@@ -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=…&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=…&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))} · ${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);
|
||||
|
||||
Reference in New Issue
Block a user