@@ -156,14 +187,19 @@ $("#search-form").addEventListener("submit", async (e) => {
volume: volInput && volInput.value ? parseInt(volInput.value) : null,
}),
});
+ setLastLib(type, libId);
btn.textContent = "✓ Angefragt";
btn.disabled = true;
+ refreshMissingBadge();
} catch (err) { toast(err.message, true); }
})
);
} catch (err) {
box.innerHTML = "";
toast(err.message, true);
+ } finally {
+ searchBtn.disabled = false;
+ searchBtn.textContent = "Suchen";
}
});
@@ -215,11 +251,21 @@ $("#manual-form").addEventListener("submit", async (e) => {
});
toast("Anfrage angelegt");
}
+ setLastLib(data.media_type, data.library_id);
e.target.reset();
+ refreshMissingBadge();
} catch (err) { toast(err.message, true); }
});
// ---- missing / imported lists ----
+let currentRequests = { missing: [], imported: [] };
+
+function requestMatchesFilter(r, text) {
+ if (!text) return true;
+ const hay = `${r.title} ${r.authors} ${r.series} ${r.narrator}`.toLowerCase();
+ return hay.includes(text.toLowerCase());
+}
+
async function loadRequests(status) {
const params = new URLSearchParams({ status });
if (status === "missing") {
@@ -228,37 +274,133 @@ async function loadRequests(status) {
if (t) params.set("media_type", t);
if (l) params.set("library_id", l);
}
- const reqs = await api("/api/requests?" + params);
+ currentRequests[status] = await api("/api/requests?" + params);
+ renderRequests(status);
+ if (status === "missing") refreshMissingBadge();
+}
+
+function renderRequests(status) {
+ const text = status === "missing" ? $("#missing-search").value : $("#imported-search").value;
+ const reqs = currentRequests[status].filter((r) => requestMatchesFilter(r, text));
const box = status === "missing" ? $("#missing-list") : $("#imported-list");
- if (!reqs.length) { box.innerHTML = "
Nichts hier.
Nichts hier.
";
+ if (status === "missing") $("#missing-bulk-delete").hidden = true;
+ return;
+ }
box.innerHTML = reqs
.map(
- (r) => `
- ${r.cover_url ? `
})
` : '
?
'}
+ (r) => `
+ ${r.cover_url ? `
})
` : '
?
'}
${esc(r.title)}${r.volume != null ? " · Band " + r.volume : ""}
${esc(r.authors)}
+ ${r.narrator ? `
🎙 ${esc(r.narrator)}` : ""}
${esc(r.media_type)} → ${esc(r.library_name)}
${status === "imported" ? `
${esc(r.imported_path)}` : ""}
-
+ ${status === "missing" ? `
` : ""}
`
)
.join("");
- box.querySelectorAll("[data-del-req]").forEach((b) =>
- b.addEventListener("click", async () => {
- if (!confirm("Anfrage entfernen?")) return;
- await api("/api/requests/" + b.dataset.delReq, { method: "DELETE" });
- loadRequests(status);
+ box.querySelectorAll("[data-detail]").forEach((card) =>
+ card.addEventListener("click", (ev) => {
+ if (ev.target.closest("input,button,select,a")) return;
+ openDetail(parseInt(card.dataset.detail), status);
})
);
+ if (status === "missing") {
+ const updateBulkBtn = () => {
+ const n = box.querySelectorAll("[data-sel-req]:checked").length;
+ const btn = $("#missing-bulk-delete");
+ btn.hidden = n === 0;
+ btn.textContent = `Ausgewählte entfernen (${n})`;
+ };
+ box.querySelectorAll("[data-sel-req]").forEach((c) =>
+ c.addEventListener("change", updateBulkBtn)
+ );
+ updateBulkBtn();
+ }
}
+
$("#missing-filter-type").addEventListener("change", () => loadRequests("missing"));
$("#missing-filter-library").addEventListener("change", () => loadRequests("missing"));
+$("#missing-search").addEventListener("input", () => renderRequests("missing"));
+$("#imported-search").addEventListener("input", () => renderRequests("imported"));
+
+$("#missing-bulk-delete").addEventListener("click", async () => {
+ const ids = [...$("#missing-list").querySelectorAll("[data-sel-req]:checked")]
+ .map((c) => c.dataset.selReq);
+ if (!ids.length) return;
+ if (!confirm(`${ids.length} Anfrage(n) entfernen?`)) return;
+ for (const id of ids) {
+ try { await api("/api/requests/" + id, { method: "DELETE" }); }
+ catch (err) { toast(err.message, true); }
+ }
+ toast(`${ids.length} Anfrage(n) entfernt`);
+ loadRequests("missing");
+});
+
+// ---- request detail dialog ----
+let detailRequest = null;
+
+function openDetail(id, status) {
+ const r = currentRequests[status].find((x) => x.id === id);
+ if (!r) return;
+ detailRequest = r;
+ const form = $("#detail-form");
+ form.title.value = r.title;
+ form.authors.value = r.authors || "";
+ form.narrator.value = r.narrator || "";
+ form.series.value = r.series || "";
+ form.volume.value = r.volume ?? "";
+ form.year.value = r.year ?? "";
+ form.external_id.value = r.external_id || "";
+ $("#detail-library").innerHTML = libraries
+ .filter((l) => l.media_type === r.media_type)
+ .map((l) => `
`)
+ .join("");
+ $("#detail-title").textContent = r.title;
+ $("#detail-status").textContent =
+ `${r.media_type} · ${r.status === "missing" ? "fehlt noch" : "importiert"}`;
+ $("#detail-path").textContent = r.imported_path || "";
+ const img = $("#detail-cover");
+ img.hidden = !r.cover_url;
+ $("#detail-nocover").hidden = !!r.cover_url;
+ if (r.cover_url) img.src = r.cover_url;
+ $("#detail-dialog").showModal();
+}
+
+$("#detail-form").addEventListener("submit", async (e) => {
+ if (e.submitter && e.submitter.value === "cancel") return;
+ if (!detailRequest) return;
+ const form = e.target;
+ try {
+ await api("/api/requests/" + detailRequest.id, {
+ method: "PUT",
+ body: JSON.stringify({
+ library_id: parseInt(form.library_id.value),
+ title: form.title.value,
+ authors: form.authors.value,
+ narrator: form.narrator.value,
+ series: form.series.value,
+ volume: form.volume.value ? parseInt(form.volume.value) : null,
+ year: form.year.value ? parseInt(form.year.value) : null,
+ external_id: form.external_id.value,
+ cover_url: detailRequest.cover_url || "",
+ }),
+ });
+ toast("Anfrage gespeichert");
+ loadRequests(detailRequest.status);
+ } catch (err) { toast(err.message, true); }
+});
// ---- import ----
let scanItems = [];
let missingReqs = [];
+let viewIdx = []; // indices into scanItems after filter/sort
+let importPage = 0;
+const IMPORT_PAGE_SIZE = 25;
$("#scan-btn").addEventListener("click", async () => {
const scanBtn = $("#scan-btn");
@@ -271,7 +413,7 @@ $("#scan-btn").addEventListener("click", async () => {
api("/api/requests?status=missing"),
]);
scanItems = scan.items;
- // selection state lives here, not in the DOM, so it survives paging
+ // selection state lives here, not in the DOM, so it survives paging/filtering
scanItems.forEach((item) => {
item.request_id = item.suggested_request_id;
item.checked = !!item.suggested_request_id;
@@ -279,6 +421,7 @@ $("#scan-btn").addEventListener("click", async () => {
missingReqs = reqs;
importPage = 0;
$("#scan-info").textContent = `${scan.items.length} Kandidat(en) in ${scan.download_dir}`;
+ $("#import-toolbar").hidden = scanItems.length === 0;
renderImportTable();
} catch (err) {
$("#scan-info").textContent = "";
@@ -288,26 +431,51 @@ $("#scan-btn").addEventListener("click", async () => {
}
});
-let importPage = 0;
-const IMPORT_PAGE_SIZE = 25;
+function applyImportView() {
+ const text = $("#import-filter-text").value.toLowerCase();
+ const mode = $("#import-filter-mode").value;
+ const sort = $("#import-sort").value;
+ viewIdx = scanItems
+ .map((_, i) => i)
+ .filter((i) => {
+ const item = scanItems[i];
+ if (text && !(`${item.name} ${item.rel_dir || ""}`.toLowerCase().includes(text))) return false;
+ if (mode === "suggested" && !item.suggested_request_id) return false;
+ if (mode === "unassigned" && item.request_id) return false;
+ return true;
+ });
+ if (sort === "score") viewIdx.sort((a, b) => scanItems[b].score - scanItems[a].score);
+ if (sort === "name") viewIdx.sort((a, b) => scanItems[a].name.localeCompare(scanItems[b].name));
+}
+
+function scoreCell(item) {
+ if (!item.suggested_request_id) return '
— | ';
+ const cls = item.score >= 80 ? "score-hi" : item.score >= 50 ? "score-mid" : "score-lo";
+ return `
${item.score} | `;
+}
function renderImportTable() {
+ applyImportView();
const table = $("#import-table");
const tbody = table.querySelector("tbody");
- if (!scanItems.length) {
+ if (!viewIdx.length) {
table.hidden = true;
- $("#import-btn").hidden = true;
+ $("#import-btn").hidden = scanItems.length === 0;
$("#import-pager").hidden = true;
+ tbody.innerHTML = "";
+ if (scanItems.length) {
+ $("#import-page-info").textContent = "";
+ }
return;
}
- const pages = Math.ceil(scanItems.length / IMPORT_PAGE_SIZE);
+ const pages = Math.ceil(viewIdx.length / IMPORT_PAGE_SIZE);
importPage = Math.min(importPage, pages - 1);
const start = importPage * IMPORT_PAGE_SIZE;
- const pageItems = scanItems.slice(start, start + IMPORT_PAGE_SIZE);
+ const pageIdx = viewIdx.slice(start, start + IMPORT_PAGE_SIZE);
- tbody.innerHTML = pageItems
- .map((item, k) => {
- const i = start + k;
+ tbody.innerHTML = pageIdx
+ .map((i) => {
+ const item = scanItems[i];
const opts = missingReqs
.filter((r) => r.media_type === item.media_type)
.map(
@@ -318,13 +486,16 @@ function renderImportTable() {
.join("");
return `
|
- ${esc(item.name)}${item.is_dir ? " 📁" : ""} |
+
+ ${esc(item.name)}${item.is_dir ? " 📁" : ""}
+ ${item.rel_dir ? ` ${esc(item.rel_dir)}/ ` : ""}
+ |
${esc(item.media_type)} |
|
- ${item.suggested_request_id ? item.score : "—"} |
+ ${scoreCell(item)}
`;
})
.join("");
@@ -335,7 +506,10 @@ function renderImportTable() {
b.addEventListener("click", () => openQuickDialog(parseInt(b.dataset.quick)))
);
tbody.querySelectorAll("[data-check]").forEach((c) =>
- c.addEventListener("change", () => (scanItems[c.dataset.check].checked = c.checked))
+ c.addEventListener("change", () => {
+ scanItems[c.dataset.check].checked = c.checked;
+ updateImportPageInfo();
+ })
);
tbody.querySelectorAll("[data-select]").forEach((s) =>
s.addEventListener("change", () => {
@@ -344,19 +518,47 @@ function renderImportTable() {
item.checked = !!item.request_id;
const check = tbody.querySelector(`[data-check="${s.dataset.select}"]`);
if (check) check.checked = item.checked;
+ updateImportPageInfo();
})
);
- const selected = scanItems.filter((it) => it.checked && it.request_id).length;
$("#import-pager").hidden = false;
- $("#import-page-info").textContent =
- `Seite ${importPage + 1}/${pages} · ${scanItems.length} Einträge · ${selected} ausgewählt`;
$("#import-prev").disabled = importPage === 0;
$("#import-next").disabled = importPage >= pages - 1;
+ updateImportPageInfo(pages);
+}
+
+function updateImportPageInfo(pages) {
+ pages = pages ?? Math.max(1, Math.ceil(viewIdx.length / IMPORT_PAGE_SIZE));
+ const selected = scanItems.filter((it) => it.checked && it.request_id).length;
+ const filtered = viewIdx.length !== scanItems.length ? ` (gefiltert: ${viewIdx.length})` : "";
+ $("#import-page-info").textContent =
+ `Seite ${importPage + 1}/${pages} · ${scanItems.length} Einträge${filtered} · ${selected} ausgewählt`;
}
$("#import-prev").addEventListener("click", () => { importPage--; renderImportTable(); });
$("#import-next").addEventListener("click", () => { importPage++; renderImportTable(); });
+$("#import-filter-text").addEventListener("input", () => { importPage = 0; renderImportTable(); });
+$("#import-filter-mode").addEventListener("change", () => { importPage = 0; renderImportTable(); });
+$("#import-sort").addEventListener("change", () => { importPage = 0; renderImportTable(); });
+
+$("#import-select-suggested").addEventListener("click", () => {
+ applyImportView();
+ viewIdx.forEach((i) => {
+ const item = scanItems[i];
+ if (item.suggested_request_id) {
+ item.request_id = item.request_id || item.suggested_request_id;
+ item.checked = true;
+ }
+ });
+ renderImportTable();
+});
+
+$("#import-deselect-all").addEventListener("click", () => {
+ applyImportView();
+ viewIdx.forEach((i) => (scanItems[i].checked = false));
+ renderImportTable();
+});
// ---- quick request from a scanned file ----
let quickItemIndex = null;
@@ -372,7 +574,7 @@ function cleanFileName(name) {
function openQuickDialog(i) {
quickItemIndex = i;
const item = scanItems[i];
- $("#quick-file").textContent = item.name;
+ $("#quick-file").textContent = (item.rel_dir ? item.rel_dir + "/" : "") + item.name;
$("#quick-q").value = cleanFileName(item.name);
$("#quick-library").innerHTML =
libOptions(item.media_type) || "
";
@@ -389,11 +591,14 @@ async function runQuickSearch() {
const results = await api(
`/api/search?media_type=${item.media_type}&q=${encodeURIComponent($("#quick-q").value)}`
);
- if (!results.length) { box.innerHTML = "
Keine Treffer — Suchbegriff anpassen.
"; return; }
+ if (!results.length) {
+ box.innerHTML = `
${EMPTY_HINTS[item.media_type]}
`;
+ return;
+ }
box.innerHTML = results
.map(
(r, j) => `
- ${r.cover_url ? `
})
` : '
?
'}
+ ${r.cover_url ? `
})
` : '
?
'}
${esc(r.title)}
${esc(r.authors)}
@@ -433,15 +638,20 @@ async function pickQuickResult(r) {
year: r.year, series: r.series, cover_url: r.cover_url,
}),
});
+ setLastLib(scanItems[i].media_type, libId);
missingReqs.push(req);
scanItems[i].request_id = req.id;
scanItems[i].checked = true;
renderImportTable();
+ refreshMissingBadge();
$("#quick-dialog").close();
toast(`„${req.title}" angefragt und verbunden`);
} catch (err) { toast(err.message, true); }
}
+// ---- import execution (batched, with progress) ----
+const IMPORT_BATCH_SIZE = 20;
+
$("#import-btn").addEventListener("click", async () => {
const items = scanItems
.filter((item) => item.checked && item.request_id)
@@ -452,18 +662,30 @@ $("#import-btn").addEventListener("click", async () => {
if (!items.length) { toast("Nichts ausgewählt", true); return; }
const btn = $("#import-btn");
btn.disabled = true;
- btn.innerHTML = `
Importiere ${items.length} Einträge…`;
+ const allResults = [];
try {
- const res = await api("/api/import", { method: "POST", body: JSON.stringify({ items }) });
- $("#import-results").innerHTML = res.results
+ for (let done = 0; done < items.length; done += IMPORT_BATCH_SIZE) {
+ btn.innerHTML =
+ `
Importiere… ${Math.min(done + IMPORT_BATCH_SIZE, items.length)}/${items.length}`;
+ const batch = items.slice(done, done + IMPORT_BATCH_SIZE);
+ const res = await api("/api/import", { method: "POST", body: JSON.stringify({ items: batch }) });
+ allResults.push(...res.results);
+ }
+ const failed = allResults.filter((r) => !r.ok);
+ $("#import-summary").innerHTML =
+ `
` +
+ `${allResults.length - failed.length} importiert` +
+ (failed.length ? `, ${failed.length} Fehler` : "") +
+ `
`;
+ $("#import-results").innerHTML = [...failed, ...allResults.filter((r) => r.ok)]
.map((r) =>
r.ok
? `
✓ ${esc(r.path)} → ${esc(r.dest)}
`
: `
✗ ${esc(r.path)}: ${esc(r.error)}
`
)
.join("");
- const failed = res.results.filter((r) => !r.ok).length;
- toast(failed ? `Import fertig, ${failed} Fehler` : "Import fertig", failed > 0);
+ toast(failed.length ? `Import fertig, ${failed.length} Fehler` : "Import fertig", failed.length > 0);
+ refreshMissingBadge();
$("#scan-btn").click();
} catch (err) { toast(err.message, true); }
finally {
@@ -474,3 +696,4 @@ $("#import-btn").addEventListener("click", async () => {
// ---- init ----
loadLibraries();
+refreshMissingBadge();
diff --git a/static/index.html b/static/index.html
index 12c9ad6..9991ed7 100644
--- a/static/index.html
+++ b/static/index.html
@@ -4,6 +4,9 @@
wordarr
+
@@ -11,7 +14,9 @@
📚 wordarr