From 07c988c44346c381bef1cda9c452940c8de44eac Mon Sep 17 00:00:00 2001 From: Steppenstreuner Date: Sun, 25 Oct 2026 19:40:10 +0100 Subject: [PATCH 1/2] feat(requests): add bulk update, bulk delete and a count endpoint --- tests/test_import_flow.py | 24 ++++++++++++++++++++ wordarr/api/requests.py | 47 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/tests/test_import_flow.py b/tests/test_import_flow.py index 2e8b838..74ba814 100644 --- a/tests/test_import_flow.py +++ b/tests/test_import_flow.py @@ -1270,3 +1270,27 @@ def test_connections_hear_about_committed_rows(client, monkeypatch): calls.clear() client.delete(f"/api/requests/{req_id}") 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 diff --git a/wordarr/api/requests.py b/wordarr/api/requests.py index 7b4d221..093026f 100644 --- a/wordarr/api/requests.py +++ b/wordarr/api/requests.py @@ -2,7 +2,7 @@ from pathlib import Path from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from pydantic import BaseModel, ConfigDict -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session 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} +@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) def update_request(request_id: int, data: RequestIn, session: Session = Depends(get_session)): req = session.get(BookRequest, request_id) -- 2.54.0 From 46b88e2fd5ddb1fb873ad86e87e27822e502c2e3 Mon Sep 17 00:00:00 2001 From: Steppenstreuner Date: Sun, 25 Oct 2026 19:40:28 +0100 Subject: [PATCH 2/2] feat(ui): sort request lists and move selected requests to another library --- static/app.js | 55 ++++++++++++++++++++++++++++++++++++++++------- static/i18n.js | 14 ++++++++++++ static/index.html | 13 +++++++++++ 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/static/app.js b/static/app.js index efce031..7bcadde 100644 --- a/static/app.js +++ b/static/app.js @@ -84,10 +84,10 @@ function libOptions(mediaType) { // ---- missing badge ---- async function refreshMissingBadge() { try { - const reqs = await api("/api/requests?status=missing"); + const { count } = await api("/api/requests/count?status=missing"); const badge = $("#missing-badge"); - badge.textContent = reqs.length; - badge.hidden = reqs.length === 0; + badge.textContent = count; + badge.hidden = count === 0; } catch {} } @@ -519,6 +519,22 @@ function requestMatchesFilter(r, text) { 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) { const params = new URLSearchParams({ status }); if (status === "missing") { @@ -543,11 +559,15 @@ async function loadRequests(status) { function renderRequests(status) { const text = status === "missing" ? $("#missing-search").value : $("#imported-search").value; 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 bulkBtn = status === "missing" ? $("#missing-bulk-delete") : $("#imported-bulk-delete"); + const moveSel = status === "missing" ? $("#missing-move-library") : null; if (!reqs.length) { box.innerHTML = `

${t("common.nothingHere")}

`; bulkBtn.hidden = true; + if (moveSel) moveSel.hidden = true; return; } box.innerHTML = reqs @@ -582,6 +602,10 @@ function renderRequests(status) { const n = box.querySelectorAll("[data-sel-req]:checked").length; bulkBtn.hidden = n === 0; bulkBtn.textContent = t("common.removeSelectedN", { n }); + if (!moveSel) return; + moveSel.hidden = n === 0; + moveSel.innerHTML = `` + + libraries.map((l) => ``).join(""); }; box.querySelectorAll("[data-sel-req]").forEach((c) => c.addEventListener("change", updateBulkBtn) @@ -596,14 +620,29 @@ async function bulkDeleteRequests(status) { const hint = t(status === "imported" ? "list.confirmDeleteImported" : "list.confirmDeleteRequests", { n: ids.length }); if (!confirm(hint)) return; - for (const id of ids) { - try { await api("/api/requests/" + id, { method: "DELETE" }); } - catch (err) { toast(err.message, true); } - } - toast(t("list.deletedN", { n: ids.length })); + try { + const res = await api("/api/requests/bulk", { + method: "DELETE", body: JSON.stringify({ ids: ids.map(Number) }), + }); + toast(t("list.deletedN", { n: res.deleted })); + } catch (err) { toast(err.message, true); } 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")); function selectAllRequests(status) { diff --git a/static/i18n.js b/static/i18n.js index f0755a7..745278b 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -106,6 +106,13 @@ const STRINGS = { "list.confirmDeleteRequests": "{n} Anfrage(n) entfernen?", "list.confirmDeleteImported": "{n} Eintrag/Einträge entfernen? Die importierten Dateien in der Library bleiben erhalten.", "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.statusImported": "importiert", @@ -339,6 +346,13 @@ const STRINGS = { "list.confirmDeleteRequests": "Remove {n} request(s)?", "list.confirmDeleteImported": "Remove {n} entr(y/ies)? The imported files stay in the library.", "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.statusImported": "imported", diff --git a/static/index.html b/static/index.html index 43cb977..73331d6 100644 --- a/static/index.html +++ b/static/index.html @@ -110,7 +110,14 @@ + +
@@ -124,6 +131,12 @@