Feature/wanted bulk edit #4
No files matched your search
+47
-8
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user