Merge pull request 'Feature/wanted bulk edit' (#4) from feature/wanted-bulk-edit into main
Build image / image (push) Successful in 50s
Build image / image (push) Successful in 50s
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
commit
44bf65b3a5
5 files changed
+144
-9
No files matched your search
+47
-8
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user