Feature/wanted bulk edit #4

Merged
admin merged 2 commits from feature/wanted-bulk-edit into main 2026-10-25 19:47:16 +01:00
5 changed files with 144 additions and 9 deletions

No files matched your search

+47 -8
View File
@@ -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 = `<p class='muted'>${t("common.nothingHere")}</p>`;
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 = `<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) =>
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) {
+14
View File
@@ -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",
+13
View File
@@ -110,7 +110,14 @@
<option value="" data-i18n="list.allLibraries"></option>
</select>
<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>
<select id="missing-move-library" hidden></select>
<button id="missing-bulk-delete" class="danger" hidden data-i18n="common.removeSelected"></button>
<div class="view-toggle" role="group" data-i18n-aria="list.view">
<button data-viewmode="list" data-i18n-title="list.viewList">☰</button>
@@ -124,6 +131,12 @@
<section id="view-imported" hidden>
<div class="bar wrap">
<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-bulk-delete" class="danger" hidden data-i18n="common.removeSelected"></button>
<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()
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
+46 -1
View File
@@ -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)