Merge pull request 'Feature/wanted bulk edit' (#4) from feature/wanted-bulk-edit into main
Build image / image (push) Successful in 50s

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
admin committed 2026-10-25 19:47:16 +01:00
commit 44bf65b3a5
5 files changed
+144 -9

No files matched your search

+47 -8
View File
@@ -84,10 +84,10 @@ function libOptions(mediaType) {
// ---- missing badge ---- // ---- missing badge ----
async function refreshMissingBadge() { async function refreshMissingBadge() {
try { try {
const reqs = await api("/api/requests?status=missing"); const { count } = await api("/api/requests/count?status=missing");
const badge = $("#missing-badge"); const badge = $("#missing-badge");
badge.textContent = reqs.length; badge.textContent = count;
badge.hidden = reqs.length === 0; badge.hidden = count === 0;
} catch {} } catch {}
} }
@@ -519,6 +519,22 @@ function requestMatchesFilter(r, text) {
return hay.includes(text.toLowerCase()); return hay.includes(text.toLowerCase());
} }
// the server already sends newest first
const SORTS = {
title: (a, b) => a.title.localeCompare(b.title, undefined, { numeric: true }),
author: (a, b) => a.authors.localeCompare(b.authors) || SORTS.title(a, b),
series: (a, b) => (a.series || a.title).localeCompare(b.series || b.title)
|| (a.volume ?? 0) - (b.volume ?? 0) || SORTS.title(a, b),
};
for (const status of ["missing", "imported"]) {
const sel = $(`#${status}-sort`);
try { sel.value = localStorage.getItem("wordarr.sort." + status) || "newest"; } catch {}
sel.addEventListener("change", () => {
try { localStorage.setItem("wordarr.sort." + status, sel.value); } catch {}
renderRequests(status);
});
}
async function loadRequests(status) { async function loadRequests(status) {
const params = new URLSearchParams({ status }); const params = new URLSearchParams({ status });
if (status === "missing") { if (status === "missing") {
@@ -543,11 +559,15 @@ async function loadRequests(status) {
function renderRequests(status) { function renderRequests(status) {
const text = status === "missing" ? $("#missing-search").value : $("#imported-search").value; const text = status === "missing" ? $("#missing-search").value : $("#imported-search").value;
const reqs = currentRequests[status].filter((r) => requestMatchesFilter(r, text)); const reqs = currentRequests[status].filter((r) => requestMatchesFilter(r, text));
const sort = SORTS[$(`#${status}-sort`).value];
if (sort) reqs.sort(sort);
const box = status === "missing" ? $("#missing-list") : $("#imported-list"); const box = status === "missing" ? $("#missing-list") : $("#imported-list");
const bulkBtn = status === "missing" ? $("#missing-bulk-delete") : $("#imported-bulk-delete"); const bulkBtn = status === "missing" ? $("#missing-bulk-delete") : $("#imported-bulk-delete");
const moveSel = status === "missing" ? $("#missing-move-library") : null;
if (!reqs.length) { if (!reqs.length) {
box.innerHTML = `<p class='muted'>${t("common.nothingHere")}</p>`; box.innerHTML = `<p class='muted'>${t("common.nothingHere")}</p>`;
bulkBtn.hidden = true; bulkBtn.hidden = true;
if (moveSel) moveSel.hidden = true;
return; return;
} }
box.innerHTML = reqs box.innerHTML = reqs
@@ -582,6 +602,10 @@ function renderRequests(status) {
const n = box.querySelectorAll("[data-sel-req]:checked").length; const n = box.querySelectorAll("[data-sel-req]:checked").length;
bulkBtn.hidden = n === 0; bulkBtn.hidden = n === 0;
bulkBtn.textContent = t("common.removeSelectedN", { n }); bulkBtn.textContent = t("common.removeSelectedN", { n });
if (!moveSel) return;
moveSel.hidden = n === 0;
moveSel.innerHTML = `<option value="">${esc(t("list.moveTo", { n }))}</option>` +
libraries.map((l) => `<option value="${l.id}">${esc(l.name)} (${esc(l.media_type)})</option>`).join("");
}; };
box.querySelectorAll("[data-sel-req]").forEach((c) => box.querySelectorAll("[data-sel-req]").forEach((c) =>
c.addEventListener("change", updateBulkBtn) c.addEventListener("change", updateBulkBtn)
@@ -596,14 +620,29 @@ async function bulkDeleteRequests(status) {
const hint = t(status === "imported" ? "list.confirmDeleteImported" : "list.confirmDeleteRequests", const hint = t(status === "imported" ? "list.confirmDeleteImported" : "list.confirmDeleteRequests",
{ n: ids.length }); { n: ids.length });
if (!confirm(hint)) return; if (!confirm(hint)) return;
for (const id of ids) { try {
try { await api("/api/requests/" + id, { method: "DELETE" }); } const res = await api("/api/requests/bulk", {
catch (err) { toast(err.message, true); } method: "DELETE", body: JSON.stringify({ ids: ids.map(Number) }),
} });
toast(t("list.deletedN", { n: ids.length })); toast(t("list.deletedN", { n: res.deleted }));
} catch (err) { toast(err.message, true); }
loadRequests(status); loadRequests(status);
} }
$("#missing-move-library").addEventListener("change", async (ev) => {
const lib = libraries.find((l) => String(l.id) === ev.target.value);
if (!lib) return;
const ids = [...$("#missing-list").querySelectorAll("[data-sel-req]:checked")]
.map((c) => Number(c.dataset.selReq));
try {
const res = await api("/api/requests/bulk", {
method: "PUT", body: JSON.stringify({ ids, library_id: lib.id }),
});
toast(t("list.movedN", { n: res.updated, library: lib.name }));
} catch (err) { toast(err.message, true); }
loadRequests("missing");
});
$("#imported-bulk-delete").addEventListener("click", () => bulkDeleteRequests("imported")); $("#imported-bulk-delete").addEventListener("click", () => bulkDeleteRequests("imported"));
function selectAllRequests(status) { function selectAllRequests(status) {
+14
View File
@@ -106,6 +106,13 @@ const STRINGS = {
"list.confirmDeleteRequests": "{n} Anfrage(n) entfernen?", "list.confirmDeleteRequests": "{n} Anfrage(n) entfernen?",
"list.confirmDeleteImported": "{n} Eintrag/Einträge entfernen? Die importierten Dateien in der Library bleiben erhalten.", "list.confirmDeleteImported": "{n} Eintrag/Einträge entfernen? Die importierten Dateien in der Library bleiben erhalten.",
"list.deletedN": "{n} entfernt", "list.deletedN": "{n} entfernt",
"list.sort": "Sortierung",
"list.sortNewest": "Neueste zuerst",
"list.sortTitle": "Titel",
"list.sortAuthor": "Autor",
"list.sortSeries": "Serie und Band",
"list.moveTo": "Bibliothek ändern ({n})…",
"list.movedN": "{n} nach {library} verschoben",
"detail.statusMissing": "fehlt noch", "detail.statusMissing": "fehlt noch",
"detail.statusImported": "importiert", "detail.statusImported": "importiert",
@@ -339,6 +346,13 @@ const STRINGS = {
"list.confirmDeleteRequests": "Remove {n} request(s)?", "list.confirmDeleteRequests": "Remove {n} request(s)?",
"list.confirmDeleteImported": "Remove {n} entr(y/ies)? The imported files stay in the library.", "list.confirmDeleteImported": "Remove {n} entr(y/ies)? The imported files stay in the library.",
"list.deletedN": "{n} removed", "list.deletedN": "{n} removed",
"list.sort": "Sort order",
"list.sortNewest": "Newest first",
"list.sortTitle": "Title",
"list.sortAuthor": "Author",
"list.sortSeries": "Series and volume",
"list.moveTo": "Change library ({n})…",
"list.movedN": "{n} moved to {library}",
"detail.statusMissing": "still missing", "detail.statusMissing": "still missing",
"detail.statusImported": "imported", "detail.statusImported": "imported",
+13
View File
@@ -110,7 +110,14 @@
<option value="" data-i18n="list.allLibraries"></option> <option value="" data-i18n="list.allLibraries"></option>
</select> </select>
<input id="missing-search" data-i18n-placeholder="list.filterPlaceholder" /> <input id="missing-search" data-i18n-placeholder="list.filterPlaceholder" />
<select id="missing-sort" data-i18n-title="list.sort">
<option value="newest" data-i18n="list.sortNewest"></option>
<option value="title" data-i18n="list.sortTitle"></option>
<option value="author" data-i18n="list.sortAuthor"></option>
<option value="series" data-i18n="list.sortSeries"></option>
</select>
<button id="missing-select-all" class="secondary" data-i18n="common.selectAll"></button> <button id="missing-select-all" class="secondary" data-i18n="common.selectAll"></button>
<select id="missing-move-library" hidden></select>
<button id="missing-bulk-delete" class="danger" hidden data-i18n="common.removeSelected"></button> <button id="missing-bulk-delete" class="danger" hidden data-i18n="common.removeSelected"></button>
<div class="view-toggle" role="group" data-i18n-aria="list.view"> <div class="view-toggle" role="group" data-i18n-aria="list.view">
<button data-viewmode="list" data-i18n-title="list.viewList">☰</button> <button data-viewmode="list" data-i18n-title="list.viewList">☰</button>
@@ -124,6 +131,12 @@
<section id="view-imported" hidden> <section id="view-imported" hidden>
<div class="bar wrap"> <div class="bar wrap">
<input id="imported-search" data-i18n-placeholder="list.filterPlaceholder" /> <input id="imported-search" data-i18n-placeholder="list.filterPlaceholder" />
<select id="imported-sort" data-i18n-title="list.sort">
<option value="newest" data-i18n="list.sortNewest"></option>
<option value="title" data-i18n="list.sortTitle"></option>
<option value="author" data-i18n="list.sortAuthor"></option>
<option value="series" data-i18n="list.sortSeries"></option>
</select>
<button id="imported-select-all" class="secondary" data-i18n="common.selectAll"></button> <button id="imported-select-all" class="secondary" data-i18n="common.selectAll"></button>
<button id="imported-bulk-delete" class="danger" hidden data-i18n="common.removeSelected"></button> <button id="imported-bulk-delete" class="danger" hidden data-i18n="common.removeSelected"></button>
<div class="view-toggle" role="group" data-i18n-aria="list.view"> <div class="view-toggle" role="group" data-i18n-aria="list.view">
+24
View File
@@ -1270,3 +1270,27 @@ def test_connections_hear_about_committed_rows(client, monkeypatch):
calls.clear() calls.clear()
client.delete(f"/api/requests/{req_id}") client.delete(f"/api/requests/{req_id}")
assert calls == [("POST", "http://hook/in")] # nothing new on disk, no scan assert calls == [("POST", "http://hook/in")] # nothing new on disk, no scan
def test_bulk_move_delete_and_count(client):
def lib(name, media_type):
return client.post("/api/libraries", json={
"name": name, "media_type": media_type,
"root_path": str(client.tmp_path / name)}).json()["id"]
a, b, audio = lib("a", "ebook"), lib("b", "ebook"), lib("audio", "audiobook")
ids = [client.post("/api/requests", json={"library_id": a, "title": f"T{i}"}).json()["id"]
for i in range(3)]
assert client.get("/api/requests/count?status=missing").json() == {"count": 3}
assert client.put("/api/requests/bulk", json={
"ids": ids[:2], "library_id": audio}).status_code == 400
assert client.put("/api/requests/bulk", json={
"ids": ids[:2], "library_id": b}).json() == {"updated": 2}
assert [r["id"] for r in client.get(f"/api/requests?library_id={b}").json()] \
== sorted(ids[:2], reverse=True)
assert client.request("DELETE", "/api/requests/bulk",
json={"ids": ids[1:]}).json() == {"deleted": 2}
assert client.get("/api/requests/count?status=missing").json() == {"count": 1}
assert [r["event"] for r in client.get("/api/history").json()] == ["deleted"] * 2
+46 -1
View File
@@ -2,7 +2,7 @@ from pathlib import Path
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import config, connect from .. import config, connect
@@ -176,6 +176,51 @@ def create_bulk_items(data: BulkItemsIn, session: Session = Depends(get_session)
return {"created": [_out(r) for r in created], "skipped": skipped} return {"created": [_out(r) for r in created], "skipped": skipped}
@router.get("/count")
def count_requests(status: str | None = None, session: Session = Depends(get_session)):
stmt = select(func.count()).select_from(BookRequest)
if status:
stmt = stmt.where(BookRequest.status == status)
return {"count": session.scalar(stmt)}
class BulkIdsIn(BaseModel):
ids: list[int]
class BulkUpdateIn(BulkIdsIn):
library_id: int
# registered before the /{request_id} routes, which would try "bulk" as an id
@router.put("/bulk")
def update_bulk(data: BulkUpdateIn, session: Session = Depends(get_session)):
"""Move requests to another library. Imported files stay where they are
until the request is relocated."""
lib = session.get(Library, data.library_id)
if not lib:
raise HTTPException(404, "library not found")
reqs = session.scalars(select(BookRequest).where(BookRequest.id.in_(data.ids))).all()
if any(r.media_type != lib.media_type for r in reqs):
raise HTTPException(400, "library has a different media type")
for r in reqs:
r.library_id = lib.id
session.commit()
return {"updated": len(reqs)}
@router.delete("/bulk")
def delete_bulk(data: BulkIdsIn, background: BackgroundTasks,
session: Session = Depends(get_session)):
reqs = session.scalars(select(BookRequest).where(BookRequest.id.in_(data.ids))).all()
rows = [record(session, "deleted", r, source=r.imported_path) for r in reqs]
for r in reqs:
session.delete(r)
session.commit()
connect.schedule(background, rows)
return {"deleted": len(reqs)}
@router.put("/{request_id}", response_model=RequestOut) @router.put("/{request_id}", response_model=RequestOut)
def update_request(request_id: int, data: RequestIn, session: Session = Depends(get_session)): def update_request(request_id: int, data: RequestIn, session: Session = Depends(get_session)):
req = session.get(BookRequest, request_id) req = session.get(BookRequest, request_id)