From 170c551d6e8886ba28972e69cfa24503c40f5b0b Mon Sep 17 00:00:00 2001 From: sabo Date: Thu, 10 Sep 2026 12:34:26 +0300 Subject: [PATCH] =?UTF-8?q?Move=20help-file=20rescan=20off=20Home=20into?= =?UTF-8?q?=20a=20dedicated=20=D0=A1=D0=BA=D0=B0=D0=BD=D0=B8=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B5=20tab=20with=20separate=20file=20and=20folder=20pi?= =?UTF-8?q?ckers.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- webapp/rescan.py | 84 +++++++++++----- webapp/templates/viewer.html | 190 +++++++++++++++++++++++++++++------ 2 files changed, 219 insertions(+), 55 deletions(-) diff --git a/webapp/rescan.py b/webapp/rescan.py index 79de0c0..24f6f0f 100644 --- a/webapp/rescan.py +++ b/webapp/rescan.py @@ -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(), diff --git a/webapp/templates/viewer.html b/webapp/templates/viewer.html index 8d8451e..b61f6ff 100644 --- a/webapp/templates/viewer.html +++ b/webapp/templates/viewer.html @@ -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 @@
{% if home_url %}
Начало
{% endif %} -
Ключови думи
+
Сканиране
+
Ключови думи
Търсене
Документ
API
@@ -312,20 +330,35 @@
Home -
-

Rescan — качи help файлове

-
- - - -
-
ZIP или един файл → обработка на сървъра → БД + Output
-
{% endif %} -
+
+
+

Help файлове

+
+ + +
+ + +
+
+ + +
+
Избери файл или папка.
+
+
+ +
@@ -424,7 +457,7 @@
POST/api/keywords/{code}
Body JSON: {"keywords":"а, б, в"} — обновява ключови думи.
POST/api/rescan -
multipart: file (zip/docx/…), prefix, force. Header опционално: X-Rescan-Token.
+
multipart: file (един или повече: zip/docx/…), prefix, force. Header опционално: X-Rescan-Token.
GET/api/rescan/{job_id}
Статус на rescan задача (queued / processing / done / error).
@@ -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 ────────────────────────────