Move help-file rescan off Home into a dedicated Сканиране tab with separate file and folder pickers.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-10 12:34:26 +03:00
parent f060cf700d
commit 170c551d6e
2 changed files with 219 additions and 55 deletions

View File

@@ -136,46 +136,82 @@ def os_access_writable(path: Path) -> bool:
return False return False
def _unique_dest(dest_dir: Path, name: str) -> Path:
target = dest_dir / name
if not target.exists():
return target
stem, suffix = Path(name).stem, Path(name).suffix
i = 2
while True:
cand = dest_dir / f"{stem}_{i}{suffix}"
if not cand.exists():
return cand
i += 1
async def _stage_uploads(uploads: list[UploadFile], input_dir: Path) -> tuple[int, str]:
"""Write one or more uploads into input_dir. Returns (file_count, label)."""
saved = 0
total = 0
labels: list[str] = []
max_bytes = 120 * 1024 * 1024
for up in uploads:
name = (up.filename or "").strip()
if not name:
continue
base = Path(name.replace("\\", "/")).name
if not base or base.startswith("."):
continue
ext = Path(base).suffix.lower()
if ext not in ALLOWED_EXT:
continue
data = await up.read()
if not data:
continue
total += len(data)
if total > max_bytes:
raise HTTPException(400, "Upload too large (max 120 MB)")
if ext == ".zip":
n = _safe_extract_zip(data, input_dir)
saved += n
else:
_unique_dest(input_dir, base).write_bytes(data)
saved += 1
labels.append(base)
if saved == 0:
raise HTTPException(
400,
f"No usable files. Allowed: {sorted(ALLOWED_EXT)}",
)
label = labels[0] if len(labels) == 1 else f"{len(labels)} files"
return saved, label
@router.post("/rescan") @router.post("/rescan")
async def rescan( async def rescan(
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
file: UploadFile = File(..., description="ZIP or single help file"), file: list[UploadFile] = File(..., description="ZIP, one help file, or several files from a folder"),
prefix: str = Form("RIP"), prefix: str = Form("RIP"),
force: str = Form("false"), force: str = Form("false"),
x_rescan_token: Optional[str] = Header(None, alias="X-Rescan-Token"), x_rescan_token: Optional[str] = Header(None, alias="X-Rescan-Token"),
token: Optional[str] = Form(None), token: Optional[str] = Form(None),
): ):
""" """
Upload a .zip (or one .docx/.html/.pdf/.txt/.doc), process on server, Upload a .zip, one .docx/.html/.pdf/.txt/.doc, or several such files
write sections to OUTPUT_DIR and Postgres. (folder pick). Process on server, write sections to OUTPUT_DIR and Postgres.
""" """
_check_token(x_rescan_token or token) _check_token(x_rescan_token or token)
name = (file.filename or "upload.bin").strip()
ext = Path(name).suffix.lower()
if ext not in ALLOWED_EXT:
raise HTTPException(
400,
f"Unsupported type {ext}. Allowed: {sorted(ALLOWED_EXT)}",
)
data = await file.read()
if not data:
raise HTTPException(400, "Empty upload")
if len(data) > 120 * 1024 * 1024:
raise HTTPException(400, "Upload too large (max 120 MB)")
staging = Path(tempfile.mkdtemp(prefix="rip_rescan_")) staging = Path(tempfile.mkdtemp(prefix="rip_rescan_"))
input_dir = staging / "input" input_dir = staging / "input"
input_dir.mkdir(parents=True, exist_ok=True) input_dir.mkdir(parents=True, exist_ok=True)
try: try:
if ext == ".zip": _count, label = await _stage_uploads(file, input_dir)
n = _safe_extract_zip(data, input_dir)
if n == 0:
raise HTTPException(400, "ZIP has no usable files")
else:
(input_dir / Path(name).name).write_bytes(data)
except HTTPException: except HTTPException:
shutil.rmtree(staging, ignore_errors=True) shutil.rmtree(staging, ignore_errors=True)
raise raise
@@ -189,7 +225,7 @@ async def rescan(
job_id, job_id,
status="queued", status="queued",
message="accepted", message="accepted",
filename=name, filename=label,
prefix=prefix, prefix=prefix,
force=force_flag, force=force_flag,
created_at=datetime.now(timezone.utc).isoformat(), created_at=datetime.now(timezone.utc).isoformat(),

View File

@@ -79,7 +79,8 @@
padding: 7px 16px; border-radius: var(--radius); border: none; padding: 7px 16px; border-radius: var(--radius); border: none;
font-family: var(--mono); font-size: 12px; cursor: pointer; transition: opacity .15s; font-family: var(--mono); font-size: 12px; cursor: pointer; transition: opacity .15s;
} }
button:hover { opacity: .85; } button:hover:not(:disabled) { opacity: .85; }
button:disabled { opacity: .5; cursor: default; }
.btn-primary { background: var(--accent); color: #fff; } .btn-primary { background: var(--accent); color: #fff; }
.btn-success { background: var(--accent2); color: #fff; } .btn-success { background: var(--accent2); color: #fff; }
.btn-danger { background: var(--danger); color: #fff; } .btn-danger { background: var(--danger); color: #fff; }
@@ -234,13 +235,13 @@
.sep { color: var(--border); margin: 0 4px; } .sep { color: var(--border); margin: 0 4px; }
.home-wrap { display: flex; flex-direction: column; align-items: center; gap: 20px; padding: 24px; } .home-wrap { display: flex; flex-direction: column; align-items: center; padding: 24px; }
.home-wrap img { .home-wrap img {
max-width: min(920px, 100%); height: auto; border-radius: var(--radius-lg); max-width: min(920px, 100%); height: auto; border-radius: var(--radius-lg);
border: 1px solid var(--border); box-shadow: 0 8px 24px rgba(0,0,0,.08); border: 1px solid var(--border); box-shadow: 0 8px 24px rgba(0,0,0,.08);
} }
.rescan-box { .rescan-box {
width: min(640px, 100%); width: min(560px, 100%);
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius-lg);
padding: 16px 18px; padding: 16px 18px;
} }
@@ -248,9 +249,25 @@
font-family: var(--mono); font-size: 12px; color: var(--muted); font-family: var(--mono); font-size: 12px; color: var(--muted);
letter-spacing: .06em; text-transform: uppercase; margin-bottom: 12px; letter-spacing: .06em; text-transform: uppercase; margin-bottom: 12px;
} }
.rescan-pick { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 14px; }
.rescan-pick .format-btn { width: 100%; }
.rescan-pick .format-btn.selected {
border-color: var(--accent); color: var(--accent);
background: rgba(58,111,207,.08);
}
.rescan-file-input {
position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden;
}
.rescan-selection {
font-family: var(--mono); font-size: 12px; color: var(--text);
margin-bottom: 12px; line-height: 1.45;
}
.rescan-selection:empty { display: none; }
.rescan-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 10px; } .rescan-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 10px; }
.rescan-box input[type=file] { font-size: 12px; }
.rescan-status { font-family: var(--mono); font-size: 12px; color: var(--muted); min-height: 1.2em; } .rescan-status { font-family: var(--mono); font-size: 12px; color: var(--muted); min-height: 1.2em; }
.rescan-status.busy { color: var(--accent); }
.rescan-status.ok { color: var(--accent2); }
.rescan-status.error { color: var(--danger); }
.api-layout { display: grid; grid-template-columns: minmax(280px, 1fr) minmax(320px, 1.1fr); gap: 20px; } .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; } } @media (max-width: 900px) { .api-layout { grid-template-columns: 1fr; } }
@@ -302,7 +319,8 @@
<div class="tabs"> <div class="tabs">
{% if home_url %}<div class="tab active" onclick="switchTab('home')">Начало</div>{% endif %} {% if home_url %}<div class="tab active" onclick="switchTab('home')">Начало</div>{% endif %}
<div class="tab {% if not home_url %}active{% endif %}" onclick="switchTab('editor')">Ключови думи</div> <div class="tab {% if not home_url %}active{% endif %}" onclick="switchTab('scan')">Сканиране</div>
<div class="tab" onclick="switchTab('editor')">Ключови думи</div>
<div class="tab" onclick="switchTab('search')">Търсене</div> <div class="tab" onclick="switchTab('search')">Търсене</div>
<div class="tab" onclick="switchTab('generator')">Документ</div> <div class="tab" onclick="switchTab('generator')">Документ</div>
<div class="tab" onclick="switchTab('api')">API</div> <div class="tab" onclick="switchTab('api')">API</div>
@@ -312,20 +330,35 @@
<div id="tab-home" class="panel active"> <div id="tab-home" class="panel active">
<div class="home-wrap"> <div class="home-wrap">
<img src="{{ home_url }}" alt="Home"> <img src="{{ home_url }}" alt="Home">
<div class="rescan-box">
<h3>Rescan — качи help файлове</h3>
<div class="rescan-row">
<input type="file" id="rescan-file" accept=".zip,.docx,.doc,.html,.htm,.pdf,.txt">
<label class="stats"><input type="checkbox" id="rescan-force"> force</label>
<button class="btn-primary" id="rescan-btn" onclick="startRescan()">Старт</button>
</div>
<div class="rescan-status" id="rescan-status">ZIP или един файл → обработка на сървъра → БД + Output</div>
</div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
<div id="tab-editor" class="panel {% if not home_url %}active{% endif %}"> <div id="tab-scan" class="panel {% if not home_url %}active{% endif %}">
<div class="rescan-box">
<h3>Help файлове</h3>
<div class="rescan-pick">
<button type="button" class="format-btn" id="rescan-pick-file"
aria-pressed="false" onclick="pickScanSource('file')">Избери файл</button>
<button type="button" class="format-btn" id="rescan-pick-folder"
aria-pressed="false" onclick="pickScanSource('folder')">Избери папка</button>
</div>
<input type="file" id="rescan-file" class="rescan-file-input"
accept=".zip,.docx,.doc,.html,.htm,.pdf,.txt"
onchange="onScanFilePicked()">
<input type="file" id="rescan-folder" class="rescan-file-input"
webkitdirectory directory multiple
onchange="onScanFolderPicked()">
<div class="rescan-selection" id="rescan-selection"></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>
</div>
<div class="rescan-status" id="rescan-status">Избери файл или папка.</div>
</div>
</div>
<div id="tab-editor" class="panel">
<div class="toolbar"> <div class="toolbar">
<input type="text" class="search-box" id="editor-search" placeholder="Филтрирай по код, заглавие, ключова дума..." oninput="filterEditor()"> <input type="text" class="search-box" id="editor-search" placeholder="Филтрирай по код, заглавие, ключова дума..." oninput="filterEditor()">
<span class="stats" id="editor-stats"></span> <span class="stats" id="editor-stats"></span>
@@ -424,7 +457,7 @@
<div class="api-ep"><span class="method post">POST</span><code>/api/keywords/{code}</code> <div class="api-ep"><span class="method post">POST</span><code>/api/keywords/{code}</code>
<div class="desc">Body JSON: <code>{"keywords":"а, б, в"}</code> — обновява ключови думи.</div></div> <div class="desc">Body JSON: <code>{"keywords":"а, б, в"}</code> — обновява ключови думи.</div></div>
<div class="api-ep"><span class="method post">POST</span><code>/api/rescan</code> <div class="api-ep"><span class="method post">POST</span><code>/api/rescan</code>
<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="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="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="desc">Статус на rescan задача (<code>queued</code> / <code>processing</code> / <code>done</code> / <code>error</code>).</div></div>
@@ -490,7 +523,7 @@ function toast(msg, isErr) {
} }
function switchTab(name) { function switchTab(name) {
const order = [{% if home_url %}'home',{% endif %}'editor','search','generator','api']; const order = [{% if home_url %}'home',{% endif %}'scan','editor','search','generator','api'];
document.querySelectorAll('.tab').forEach((t,i) => t.classList.toggle('active', order[i] === name)); document.querySelectorAll('.tab').forEach((t,i) => t.classList.toggle('active', order[i] === name));
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active')); document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
const panel = document.getElementById('tab-' + name); const panel = document.getElementById('tab-' + name);
@@ -500,56 +533,151 @@ function switchTab(name) {
if (name === 'api') initApiTab(); if (name === 'api') initApiTab();
} }
async function startRescan() { const SCAN_EXT = ['.zip', '.docx', '.doc', '.html', '.htm', '.pdf', '.txt'];
let scanMode = null;
let scanFiles = [];
function scanExt(name) {
const n = String(name || '');
const i = n.lastIndexOf('.');
return i >= 0 ? n.slice(i).toLowerCase() : '';
}
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 fileCountBg(n) {
return n === 1 ? '1 файл' : n + ' файла';
}
function folderNameFrom(files) {
const p = (files[0] && files[0].webkitRelativePath) || '';
const top = p.replace(/\\/g, '/').split('/')[0];
return top || (files[0] && files[0].name) || 'папка';
}
function setScanSelection(mode, files) {
scanMode = mode;
scanFiles = files;
const fileBtn = document.getElementById('rescan-pick-file');
const folderBtn = document.getElementById('rescan-pick-folder');
fileBtn.classList.toggle('selected', mode === 'file');
folderBtn.classList.toggle('selected', mode === 'folder');
fileBtn.setAttribute('aria-pressed', mode === 'file' ? 'true' : 'false');
folderBtn.setAttribute('aria-pressed', mode === 'folder' ? 'true' : 'false');
const sel = document.getElementById('rescan-selection');
if (!mode) {
sel.textContent = '';
setScanStatus('Избери файл или папка.');
return;
}
if (mode === 'file') {
if (!files[0]) {
sel.textContent = '';
setScanStatus('Избери файл.', 'error');
return;
}
sel.textContent = 'Файл: ' + files[0].name;
setScanStatus('Готово за старт.');
return;
}
if (!files.length) {
sel.textContent = 'Папка: няма подходящи файлове';
setScanStatus('Няма .zip, .docx, .html, .pdf или .txt в папката.', 'error');
return;
}
sel.textContent = 'Папка: ' + folderNameFrom(files) + ' · ' + fileCountBg(files.length);
setScanStatus('Готово за старт.');
}
function pickScanSource(mode) {
if (document.getElementById('rescan-btn').disabled) return;
const id = mode === 'folder' ? 'rescan-folder' : 'rescan-file';
document.getElementById(id).click();
}
function onScanFilePicked() {
document.getElementById('rescan-folder').value = '';
const inp = document.getElementById('rescan-file'); const inp = document.getElementById('rescan-file');
const st = document.getElementById('rescan-status'); const f = inp.files && inp.files[0];
if (!f) { setScanSelection(null, []); return; }
if (!SCAN_EXT.includes(scanExt(f.name))) {
setScanSelection('file', []);
setScanStatus('Неподдържан тип. Допустими: zip, docx, html, pdf, txt.', 'error');
return;
}
setScanSelection('file', [f]);
}
function onScanFolderPicked() {
document.getElementById('rescan-file').value = '';
const inp = document.getElementById('rescan-folder');
const raw = inp.files ? [...inp.files] : [];
const files = raw.filter(f => {
const name = f.name || '';
if (!name || name.startsWith('.')) return false;
const path = (f.webkitRelativePath || '').replace(/\\/g, '/');
if (path.split('/').some(p => p.startsWith('.') || p === '__MACOSX')) return false;
return SCAN_EXT.includes(scanExt(name));
});
setScanSelection('folder', files);
}
async function startRescan() {
const btn = document.getElementById('rescan-btn'); const btn = document.getElementById('rescan-btn');
if (!inp.files || !inp.files[0]) { const fileBtn = document.getElementById('rescan-pick-file');
st.textContent = 'Избери файл първо.'; const folderBtn = document.getElementById('rescan-pick-folder');
if (!scanFiles.length) {
setScanStatus(scanMode === 'folder' ? 'Няма подходящи файлове в папката.' : 'Избери файл или папка.', 'error');
return; return;
} }
const fd = new FormData(); const fd = new FormData();
fd.append('file', inp.files[0]); scanFiles.forEach(f => fd.append('file', f));
fd.append('prefix', {{ (prefix or 'RIP') | tojson }}); fd.append('prefix', {{ (prefix or 'RIP') | tojson }});
fd.append('force', document.getElementById('rescan-force').checked ? 'true' : 'false'); fd.append('force', document.getElementById('rescan-force').checked ? 'true' : 'false');
const token = localStorage.getItem('RESCAN_TOKEN') || ''; const token = localStorage.getItem('RESCAN_TOKEN') || '';
if (token) fd.append('token', token); if (token) fd.append('token', token);
btn.disabled = true; btn.disabled = true;
st.textContent = 'Качване...'; fileBtn.disabled = true;
folderBtn.disabled = true;
setScanStatus('Качване...', 'busy');
try { try {
const headers = {}; const headers = {};
if (token) headers['X-Rescan-Token'] = token; if (token) headers['X-Rescan-Token'] = token;
const res = await fetch('/api/rescan', { method: 'POST', body: fd, headers }); const res = await fetch('/api/rescan', { method: 'POST', body: fd, headers });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || ('HTTP ' + res.status)); if (!res.ok) {
st.textContent = 'Job ' + data.job_id + ' — обработка...'; const detail = data.detail;
const msg = typeof detail === 'string' ? detail : (detail && JSON.stringify(detail)) || ('HTTP ' + res.status);
throw new Error(msg);
}
setScanStatus('Job ' + data.job_id + ' — обработка...', 'busy');
await pollRescan(data.job_id); await pollRescan(data.job_id);
} catch (e) { } catch (e) {
st.textContent = 'Грешка: ' + e.message; setScanStatus('Грешка: ' + e.message, 'error');
} finally { } finally {
btn.disabled = false; btn.disabled = false;
fileBtn.disabled = false;
folderBtn.disabled = false;
} }
} }
async function pollRescan(jobId) { async function pollRescan(jobId) {
const st = document.getElementById('rescan-status');
for (let i = 0; i < 180; i++) { for (let i = 0; i < 180; i++) {
await new Promise(r => setTimeout(r, 2000)); await new Promise(r => setTimeout(r, 2000));
const res = await fetch('/api/rescan/' + encodeURIComponent(jobId)); const res = await fetch('/api/rescan/' + encodeURIComponent(jobId));
const data = await res.json(); const data = await res.json();
st.textContent = data.status + (data.message ? ' — ' + data.message : ''); const line = data.status + (data.message ? ' — ' + data.message : '');
if (data.status === 'done') { if (data.status === 'done') {
st.textContent = 'Готово. Презареждане...'; setScanStatus('Готово. Презареждане...', 'ok');
location.reload(); location.reload();
return; return;
} }
if (data.status === 'error') { if (data.status === 'error') {
st.textContent = 'Грешка: ' + (data.message || 'unknown'); setScanStatus('Грешка: ' + (data.message || 'unknown'), 'error');
return; return;
} }
setScanStatus(line, 'busy');
} }
st.textContent = 'Timeout — провери /api/rescan/' + jobId; setScanStatus('Timeout — провери /api/rescan/' + jobId, 'error');
} }
// ── EDITOR ──────────────────────────── // ── EDITOR ────────────────────────────