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:
@@ -136,46 +136,82 @@ def os_access_writable(path: Path) -> bool:
|
||||
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")
|
||||
async def rescan(
|
||||
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"),
|
||||
force: str = Form("false"),
|
||||
x_rescan_token: Optional[str] = Header(None, alias="X-Rescan-Token"),
|
||||
token: Optional[str] = Form(None),
|
||||
):
|
||||
"""
|
||||
Upload a .zip (or one .docx/.html/.pdf/.txt/.doc), process on server,
|
||||
write sections to OUTPUT_DIR and Postgres.
|
||||
Upload a .zip, one .docx/.html/.pdf/.txt/.doc, or several such files
|
||||
(folder pick). Process on server, write sections to OUTPUT_DIR and Postgres.
|
||||
"""
|
||||
_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_"))
|
||||
input_dir = staging / "input"
|
||||
input_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
if ext == ".zip":
|
||||
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)
|
||||
_count, label = await _stage_uploads(file, input_dir)
|
||||
except HTTPException:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
raise
|
||||
@@ -189,7 +225,7 @@ async def rescan(
|
||||
job_id,
|
||||
status="queued",
|
||||
message="accepted",
|
||||
filename=name,
|
||||
filename=label,
|
||||
prefix=prefix,
|
||||
force=force_flag,
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
|
||||
@@ -79,7 +79,8 @@
|
||||
padding: 7px 16px; border-radius: var(--radius); border: none;
|
||||
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-success { background: var(--accent2); color: #fff; }
|
||||
.btn-danger { background: var(--danger); color: #fff; }
|
||||
@@ -234,13 +235,13 @@
|
||||
|
||||
.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 {
|
||||
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);
|
||||
}
|
||||
.rescan-box {
|
||||
width: min(640px, 100%);
|
||||
width: min(560px, 100%);
|
||||
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius-lg);
|
||||
padding: 16px 18px;
|
||||
}
|
||||
@@ -248,9 +249,25 @@
|
||||
font-family: var(--mono); font-size: 12px; color: var(--muted);
|
||||
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-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.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; }
|
||||
@media (max-width: 900px) { .api-layout { grid-template-columns: 1fr; } }
|
||||
@@ -302,7 +319,8 @@
|
||||
|
||||
<div class="tabs">
|
||||
{% 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('generator')">Документ</div>
|
||||
<div class="tab" onclick="switchTab('api')">API</div>
|
||||
@@ -312,20 +330,35 @@
|
||||
<div id="tab-home" class="panel active">
|
||||
<div class="home-wrap">
|
||||
<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>
|
||||
{% 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">
|
||||
<input type="text" class="search-box" id="editor-search" placeholder="Филтрирай по код, заглавие, ключова дума..." oninput="filterEditor()">
|
||||
<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="desc">Body JSON: <code>{"keywords":"а, б, в"}</code> — обновява ключови думи.</div></div>
|
||||
<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="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) {
|
||||
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('.panel').forEach(p => p.classList.remove('active'));
|
||||
const panel = document.getElementById('tab-' + name);
|
||||
@@ -500,56 +533,151 @@ function switchTab(name) {
|
||||
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 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');
|
||||
if (!inp.files || !inp.files[0]) {
|
||||
st.textContent = 'Избери файл първо.';
|
||||
const fileBtn = document.getElementById('rescan-pick-file');
|
||||
const folderBtn = document.getElementById('rescan-pick-folder');
|
||||
if (!scanFiles.length) {
|
||||
setScanStatus(scanMode === 'folder' ? 'Няма подходящи файлове в папката.' : 'Избери файл или папка.', 'error');
|
||||
return;
|
||||
}
|
||||
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('force', document.getElementById('rescan-force').checked ? 'true' : 'false');
|
||||
const token = localStorage.getItem('RESCAN_TOKEN') || '';
|
||||
if (token) fd.append('token', token);
|
||||
|
||||
btn.disabled = true;
|
||||
st.textContent = 'Качване...';
|
||||
fileBtn.disabled = true;
|
||||
folderBtn.disabled = true;
|
||||
setScanStatus('Качване...', 'busy');
|
||||
try {
|
||||
const headers = {};
|
||||
if (token) headers['X-Rescan-Token'] = token;
|
||||
const res = await fetch('/api/rescan', { method: 'POST', body: fd, headers });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.detail || ('HTTP ' + res.status));
|
||||
st.textContent = 'Job ' + data.job_id + ' — обработка...';
|
||||
if (!res.ok) {
|
||||
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);
|
||||
} catch (e) {
|
||||
st.textContent = 'Грешка: ' + e.message;
|
||||
setScanStatus('Грешка: ' + e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
fileBtn.disabled = false;
|
||||
folderBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollRescan(jobId) {
|
||||
const st = document.getElementById('rescan-status');
|
||||
for (let i = 0; i < 180; i++) {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
const res = await fetch('/api/rescan/' + encodeURIComponent(jobId));
|
||||
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') {
|
||||
st.textContent = 'Готово. Презареждане...';
|
||||
setScanStatus('Готово. Презареждане...', 'ok');
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
if (data.status === 'error') {
|
||||
st.textContent = 'Грешка: ' + (data.message || 'unknown');
|
||||
setScanStatus('Грешка: ' + (data.message || 'unknown'), 'error');
|
||||
return;
|
||||
}
|
||||
setScanStatus(line, 'busy');
|
||||
}
|
||||
st.textContent = 'Timeout — провери /api/rescan/' + jobId;
|
||||
setScanStatus('Timeout — провери /api/rescan/' + jobId, 'error');
|
||||
}
|
||||
|
||||
// ── EDITOR ────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user