Merge pull request 'Feature/library adopt' (#5) from feature/library-adopt into main
Build image / image (push) Successful in 51s
Build image / image (push) Successful in 51s
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
commit
0d2ada3901
5 files changed
+218
-3
No files matched your search
+75
-1
@@ -126,6 +126,7 @@ async function loadLibraries() {
|
|||||||
<td>${l.language ? esc(languageName(l.language)) : "—"}</td>
|
<td>${l.language ? esc(languageName(l.language)) : "—"}</td>
|
||||||
<td class="row">
|
<td class="row">
|
||||||
<button class="secondary" data-edit-lib="${l.id}">${t("common.edit")}</button>
|
<button class="secondary" data-edit-lib="${l.id}">${t("common.edit")}</button>
|
||||||
|
<button class="secondary" data-adopt-lib="${l.id}" title="${esc(t("adopt.buttonTitle"))}">${t("adopt.button")}</button>
|
||||||
<button class="danger" data-del-lib="${l.id}">${t("common.delete")}</button>
|
<button class="danger" data-del-lib="${l.id}">${t("common.delete")}</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`
|
</tr>`
|
||||||
@@ -134,6 +135,9 @@ async function loadLibraries() {
|
|||||||
tbody.querySelectorAll("[data-edit-lib]").forEach((b) =>
|
tbody.querySelectorAll("[data-edit-lib]").forEach((b) =>
|
||||||
b.addEventListener("click", () => startLibraryEdit(parseInt(b.dataset.editLib)))
|
b.addEventListener("click", () => startLibraryEdit(parseInt(b.dataset.editLib)))
|
||||||
);
|
);
|
||||||
|
tbody.querySelectorAll("[data-adopt-lib]").forEach((b) =>
|
||||||
|
b.addEventListener("click", () => openAdopt(parseInt(b.dataset.adoptLib)))
|
||||||
|
);
|
||||||
tbody.querySelectorAll("[data-del-lib]").forEach((b) =>
|
tbody.querySelectorAll("[data-del-lib]").forEach((b) =>
|
||||||
b.addEventListener("click", async () => {
|
b.addEventListener("click", async () => {
|
||||||
if (!confirm(t("lib.confirmDelete"))) return;
|
if (!confirm(t("lib.confirmDelete"))) return;
|
||||||
@@ -664,7 +668,7 @@ $("#missing-bulk-delete").addEventListener("click", () => bulkDeleteRequests("mi
|
|||||||
|
|
||||||
// ---- activity ----
|
// ---- activity ----
|
||||||
let historyRows = [];
|
let historyRows = [];
|
||||||
const HISTORY_STATE = { imported: "done", appended: "done", failed: "failed" };
|
const HISTORY_STATE = { imported: "done", appended: "done", adopted: "done", failed: "failed" };
|
||||||
|
|
||||||
async function loadHistory() {
|
async function loadHistory() {
|
||||||
try {
|
try {
|
||||||
@@ -1818,6 +1822,75 @@ function setImportProgress(done, total) {
|
|||||||
`${done}/${total} · ${Math.round((done / total) * 100)} %`;
|
`${done}/${total} · ${Math.round((done / total) * 100)} %`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- adopt files already in a library ----
|
||||||
|
let adoptLib = null;
|
||||||
|
let adoptScan = null;
|
||||||
|
|
||||||
|
async function openAdopt(id) {
|
||||||
|
adoptLib = libraries.find((l) => l.id === id);
|
||||||
|
adoptScan = null;
|
||||||
|
$("#adopt-heading").textContent = t("adopt.heading", { name: adoptLib.name });
|
||||||
|
$("#adopt-intro").innerHTML = `<span class="spinner"></span> ${t("import.scanning")}`;
|
||||||
|
$("#adopt-list").innerHTML = "";
|
||||||
|
$("#adopt-submit").disabled = true;
|
||||||
|
$("#adopt-dialog").showModal();
|
||||||
|
try {
|
||||||
|
adoptScan = await api("/api/import/library/" + id);
|
||||||
|
} catch (err) {
|
||||||
|
$("#adopt-intro").textContent = "";
|
||||||
|
toast(err.message, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderAdopt();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAdopt() {
|
||||||
|
if (!adoptScan) return;
|
||||||
|
// only files with a suggestion: a select per unmatched file would bury them
|
||||||
|
const matched = adoptScan.items.filter((i) => i.suggested_request_id);
|
||||||
|
$("#adopt-intro").textContent = t("adopt.intro", {
|
||||||
|
n: matched.length, rest: adoptScan.items.length - matched.length,
|
||||||
|
});
|
||||||
|
const options = adoptScan.requests
|
||||||
|
.map((r) => [r.id, r.authors ? `${r.authors}: ${r.title}` : r.title]);
|
||||||
|
$("#adopt-list").innerHTML = matched
|
||||||
|
.map((item) => `<label>
|
||||||
|
<input type="checkbox" checked data-adopt-path="${esc(item.path)}" />
|
||||||
|
<span>${esc(item.name)} <span class="muted">${esc(item.rel_dir)}</span></span>
|
||||||
|
<select>${options.map(([id, label]) =>
|
||||||
|
`<option value="${id}"${id === item.suggested_request_id ? " selected" : ""}>${esc(label)}</option>`
|
||||||
|
).join("")}</select>
|
||||||
|
</label>`)
|
||||||
|
.join("");
|
||||||
|
$("#adopt-submit").disabled = matched.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#adopt-cancel").addEventListener("click", () => $("#adopt-dialog").close());
|
||||||
|
|
||||||
|
$("#adopt-submit").addEventListener("click", async () => {
|
||||||
|
const items = [...$$("#adopt-list [data-adopt-path]:checked")].map((box) => ({
|
||||||
|
path: box.dataset.adoptPath,
|
||||||
|
request_id: parseInt(box.closest("label").querySelector("select").value),
|
||||||
|
}));
|
||||||
|
if (!items.length) return;
|
||||||
|
$("#adopt-submit").disabled = true;
|
||||||
|
try {
|
||||||
|
const { results } = await api("/api/import/library/" + adoptLib.id, {
|
||||||
|
method: "POST", body: JSON.stringify({ items }),
|
||||||
|
});
|
||||||
|
const failed = results.filter((r) => !r.ok);
|
||||||
|
toast(failed.length
|
||||||
|
? t("adopt.partial", { n: results.length - failed.length, failed: failed.map((r) => `${r.path}: ${r.error}`).join("; ") })
|
||||||
|
: t("adopt.done", { n: results.length }), failed.length > 0);
|
||||||
|
$("#adopt-dialog").close();
|
||||||
|
refreshMissingBadge();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, true);
|
||||||
|
} finally {
|
||||||
|
$("#adopt-submit").disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---- language ----
|
// ---- language ----
|
||||||
function updateLangButton() {
|
function updateLangButton() {
|
||||||
$("#lang-toggle").textContent = lang === "de" ? "EN" : "DE";
|
$("#lang-toggle").textContent = lang === "de" ? "EN" : "DE";
|
||||||
@@ -1836,6 +1909,7 @@ function onLangChange() {
|
|||||||
const editId = $("#lib-form").dataset.editId;
|
const editId = $("#lib-form").dataset.editId;
|
||||||
editId ? startLibraryEdit(parseInt(editId)) : endLibraryEdit();
|
editId ? startLibraryEdit(parseInt(editId)) : endLibraryEdit();
|
||||||
if (scanItems.length) renderImportTable();
|
if (scanItems.length) renderImportTable();
|
||||||
|
renderAdopt();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- version ----
|
// ---- version ----
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ const STRINGS = {
|
|||||||
"history.retagged": "neu getaggt",
|
"history.retagged": "neu getaggt",
|
||||||
"history.deleted": "gelöscht",
|
"history.deleted": "gelöscht",
|
||||||
"history.failed": "fehlgeschlagen",
|
"history.failed": "fehlgeschlagen",
|
||||||
|
"history.adopted": "übernommen",
|
||||||
|
"adopt.button": "Einlesen",
|
||||||
|
"adopt.buttonTitle": "Dateien, die schon in der Library liegen, offenen Anfragen zuordnen",
|
||||||
|
"adopt.heading": "„{name}“ einlesen",
|
||||||
|
"adopt.intro": "{n} Dateien passen zu offenen Anfragen dieser Library und bleiben, wo sie sind. {rest} weitere ohne Treffer.",
|
||||||
|
"adopt.submit": "Übernehmen",
|
||||||
|
"adopt.done": "{n} übernommen",
|
||||||
|
"adopt.partial": "{n} übernommen, Fehler: {failed}",
|
||||||
|
|
||||||
"search.languageTitle": "Sprache der Ausgabe — filtert Ebook- und Audiobook-Suche",
|
"search.languageTitle": "Sprache der Ausgabe — filtert Ebook- und Audiobook-Suche",
|
||||||
"search.allLanguages": "Alle Sprachen",
|
"search.allLanguages": "Alle Sprachen",
|
||||||
@@ -296,6 +304,14 @@ const STRINGS = {
|
|||||||
"history.retagged": "retagged",
|
"history.retagged": "retagged",
|
||||||
"history.deleted": "deleted",
|
"history.deleted": "deleted",
|
||||||
"history.failed": "failed",
|
"history.failed": "failed",
|
||||||
|
"history.adopted": "adopted",
|
||||||
|
"adopt.button": "Scan",
|
||||||
|
"adopt.buttonTitle": "Link files already in the library to open requests",
|
||||||
|
"adopt.heading": "Scan “{name}”",
|
||||||
|
"adopt.intro": "{n} files match open requests of this library and stay where they are. {rest} more without a match.",
|
||||||
|
"adopt.submit": "Adopt",
|
||||||
|
"adopt.done": "{n} adopted",
|
||||||
|
"adopt.partial": "{n} adopted, errors: {failed}",
|
||||||
|
|
||||||
"search.languageTitle": "Edition language — filters the ebook and audiobook search",
|
"search.languageTitle": "Edition language — filters the ebook and audiobook search",
|
||||||
"search.allLanguages": "All languages",
|
"search.allLanguages": "All languages",
|
||||||
|
|||||||
@@ -379,6 +379,16 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="adopt-dialog">
|
||||||
|
<h3 id="adopt-heading"></h3>
|
||||||
|
<p class="muted" id="adopt-intro"></p>
|
||||||
|
<div id="adopt-list" class="series-list"></div>
|
||||||
|
<div class="row">
|
||||||
|
<button type="button" id="adopt-cancel" class="secondary" data-i18n="common.cancel"></button>
|
||||||
|
<button type="button" id="adopt-submit" data-i18n="adopt.submit"></button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<div id="toast" hidden></div>
|
<div id="toast" hidden></div>
|
||||||
<script src="/i18n.js"></script>
|
<script src="/i18n.js"></script>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
|
|||||||
@@ -1294,3 +1294,37 @@ def test_bulk_move_delete_and_count(client):
|
|||||||
json={"ids": ids[1:]}).json() == {"deleted": 2}
|
json={"ids": ids[1:]}).json() == {"deleted": 2}
|
||||||
assert client.get("/api/requests/count?status=missing").json() == {"count": 1}
|
assert client.get("/api/requests/count?status=missing").json() == {"count": 1}
|
||||||
assert [r["event"] for r in client.get("/api/history").json()] == ["deleted"] * 2
|
assert [r["event"] for r in client.get("/api/history").json()] == ["deleted"] * 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_adopt_files_already_in_the_library(client):
|
||||||
|
root = client.tmp_path / "audio"
|
||||||
|
lib = client.post("/api/libraries", json={
|
||||||
|
"name": "Audio", "media_type": "audiobook", "root_path": str(root)}).json()["id"]
|
||||||
|
hobbit = client.post("/api/requests", json={
|
||||||
|
"library_id": lib, "title": "Der Hobbit", "authors": "Tolkien"}).json()["id"]
|
||||||
|
known = client.post("/api/requests", json={
|
||||||
|
"library_id": lib, "title": "Momo", "authors": "Ende"}).json()["id"]
|
||||||
|
for folder in ("Tolkien/Der Hobbit", "Ende/Momo"):
|
||||||
|
(root / folder).mkdir(parents=True)
|
||||||
|
for i in (1, 2):
|
||||||
|
(root / folder / f"{i:02d}.mp3").write_bytes(b"")
|
||||||
|
(root / "Tolkien" / "Notizen.epub").write_bytes(b"") # other media type
|
||||||
|
|
||||||
|
# Momo already points at its folder, so only the Hobbit is offered
|
||||||
|
adopted = client.post(f"/api/import/library/{lib}", json={"items": [
|
||||||
|
{"path": str(root / "Ende/Momo"), "request_id": known}]}).json()
|
||||||
|
assert adopted == {"results": [{"path": str(root / "Ende/Momo"), "ok": True}]}
|
||||||
|
items = client.get(f"/api/import/library/{lib}").json()["items"]
|
||||||
|
assert [(i["name"], i["suggested_request_id"]) for i in items] == [("Der Hobbit", hobbit)]
|
||||||
|
|
||||||
|
resp = client.post(f"/api/import/library/{lib}", json={"items": [
|
||||||
|
{"path": items[0]["path"], "request_id": hobbit},
|
||||||
|
{"path": str(root / "Ende/Momo"), "request_id": known}, # no longer open
|
||||||
|
{"path": str(client.downloads), "request_id": hobbit},
|
||||||
|
]}).json()["results"]
|
||||||
|
assert [r["ok"] for r in resp] == [True, False, False]
|
||||||
|
reqs = {r["id"]: r for r in client.get("/api/requests?status=imported").json()}
|
||||||
|
assert reqs[hobbit]["imported_path"] == str(root / "Tolkien/Der Hobbit")
|
||||||
|
assert (root / "Tolkien/Der Hobbit/01.mp3").exists() # nothing moved
|
||||||
|
assert client.get(f"/api/import/library/{lib}").json()["items"] == []
|
||||||
|
assert [h["event"] for h in client.get("/api/history").json()] == ["adopted"] * 2
|
||||||
+83
-2
@@ -6,8 +6,8 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .. import config, connect
|
from .. import config, connect
|
||||||
from ..db import BookRequest, get_session, record
|
from ..db import BookRequest, Library, get_session, record
|
||||||
from ..fsnames import decode_path, readable
|
from ..fsnames import decode_path, is_broken, readable
|
||||||
from ..importer import matcher, mover, scanner
|
from ..importer import matcher, mover, scanner
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/import", tags=["import"])
|
router = APIRouter(prefix="/api/import", tags=["import"])
|
||||||
@@ -128,3 +128,84 @@ def do_import(data: ImportIn, background: BackgroundTasks,
|
|||||||
raise HTTPException(400, "nothing to import")
|
raise HTTPException(400, "nothing to import")
|
||||||
connect.schedule(background, rows)
|
connect.schedule(background, rows)
|
||||||
return {"results": results}
|
return {"results": results}
|
||||||
|
|
||||||
|
|
||||||
|
def _library(session: Session, library_id: int) -> Library:
|
||||||
|
lib = session.get(Library, library_id)
|
||||||
|
if not lib:
|
||||||
|
raise HTTPException(404, "library not found")
|
||||||
|
return lib
|
||||||
|
|
||||||
|
|
||||||
|
def _open_for(session: Session, lib: Library) -> list[BookRequest]:
|
||||||
|
missing = session.scalars(select(BookRequest).where(
|
||||||
|
BookRequest.status == "missing", BookRequest.library_id == lib.id)).all()
|
||||||
|
return list(missing) + [r for r in _orphaned(session) if r.library_id == lib.id]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/library/{library_id}")
|
||||||
|
def scan_library(library_id: int, session: Session = Depends(get_session)):
|
||||||
|
"""Files already in the library that no request points at, matched against
|
||||||
|
the library's open requests, so an existing collection need not be imported
|
||||||
|
again."""
|
||||||
|
lib = _library(session, library_id)
|
||||||
|
imported = session.scalars(select(BookRequest.imported_path).where(
|
||||||
|
BookRequest.status == "imported", BookRequest.imported_path != "")).all()
|
||||||
|
covered = {Path(p) for p in imported}
|
||||||
|
# a folder holding an imported file, or a file inside an imported folder
|
||||||
|
above = {a for p in covered for a in p.parents}
|
||||||
|
|
||||||
|
def known(item: dict) -> bool:
|
||||||
|
p = Path(decode_path(item["path"]))
|
||||||
|
return p in covered or p in above or any(a in covered for a in p.parents)
|
||||||
|
|
||||||
|
items = [i for i in scanner.scan(Path(lib.root_path))
|
||||||
|
if i["media_type"] == lib.media_type and not known(i)]
|
||||||
|
open_reqs = _open_for(session, lib)
|
||||||
|
return {"root_path": lib.root_path,
|
||||||
|
"items": matcher.best_matches(items, open_reqs),
|
||||||
|
"requests": [{"id": r.id, "title": r.title, "authors": r.authors}
|
||||||
|
for r in open_reqs]}
|
||||||
|
|
||||||
|
|
||||||
|
class AdoptItem(BaseModel):
|
||||||
|
path: str
|
||||||
|
request_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class AdoptIn(BaseModel):
|
||||||
|
items: list[AdoptItem]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/library/{library_id}")
|
||||||
|
def adopt(library_id: int, data: AdoptIn, background: BackgroundTasks,
|
||||||
|
session: Session = Depends(get_session)):
|
||||||
|
"""Point open requests at files that are already in place. Nothing moves."""
|
||||||
|
lib = _library(session, library_id)
|
||||||
|
root = Path(lib.root_path).resolve()
|
||||||
|
open_ids = {r.id for r in _open_for(session, lib)}
|
||||||
|
results, rows = [], []
|
||||||
|
for item in data.items:
|
||||||
|
path = decode_path(item.path)
|
||||||
|
shown = readable(path)
|
||||||
|
req = session.get(BookRequest, item.request_id)
|
||||||
|
if is_broken(path):
|
||||||
|
error = "file name is not valid UTF-8, rename it first"
|
||||||
|
elif root not in Path(path).resolve().parents:
|
||||||
|
error = "path outside the library"
|
||||||
|
elif not Path(path).exists():
|
||||||
|
error = "path no longer exists"
|
||||||
|
elif not req or req.id not in open_ids:
|
||||||
|
error = f"request #{item.request_id} is not open in this library"
|
||||||
|
else:
|
||||||
|
error = ""
|
||||||
|
if error:
|
||||||
|
results.append({"path": shown, "ok": False, "error": error})
|
||||||
|
continue
|
||||||
|
req.status, req.imported_path = "imported", path
|
||||||
|
rows.append(record(session, "adopted", req, dest=path))
|
||||||
|
open_ids.discard(req.id) # two files for one request: the first wins
|
||||||
|
results.append({"path": shown, "ok": True})
|
||||||
|
session.commit()
|
||||||
|
connect.schedule(background, rows)
|
||||||
|
return {"results": results}
|
||||||
Reference in new issue
Block a user