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
|
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(),
|
||||||
|
|||||||
@@ -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 ────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user