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 @@

-
-
Rescan — качи help файлове
-
-
-
-
-
-
ZIP или един файл → обработка на сървъра → БД + Output
-
{% endif %}
-
+
+
+
Help файлове
+
+
+
+
+
+
+
+
+
+
+
+
Избери файл или папка.
+
+
+
+
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 ────────────────────────────