feat(ui): scan a library and link its files to open requests

This commit is contained in:
Steppenstreuner committed 2026-10-25 19:55:22 +01:00
1 parent 46c3c91a8d
commit d0b12d5d09
3 files changed
+101 -1

No files matched your search

+75 -1
View File
@@ -126,6 +126,7 @@ async function loadLibraries() {
<td>${l.language ? esc(languageName(l.language)) : "—"}</td>
<td class="row">
<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>
</td>
</tr>`
@@ -134,6 +135,9 @@ async function loadLibraries() {
tbody.querySelectorAll("[data-edit-lib]").forEach((b) =>
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) =>
b.addEventListener("click", async () => {
if (!confirm(t("lib.confirmDelete"))) return;
@@ -664,7 +668,7 @@ $("#missing-bulk-delete").addEventListener("click", () => bulkDeleteRequests("mi
// ---- activity ----
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() {
try {
@@ -1818,6 +1822,75 @@ function setImportProgress(done, total) {
`${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 ----
function updateLangButton() {
$("#lang-toggle").textContent = lang === "de" ? "EN" : "DE";
@@ -1836,6 +1909,7 @@ function onLangChange() {
const editId = $("#lib-form").dataset.editId;
editId ? startLibraryEdit(parseInt(editId)) : endLibraryEdit();
if (scanItems.length) renderImportTable();
renderAdopt();
}
// ---- version ----
+16
View File
@@ -56,6 +56,14 @@ const STRINGS = {
"history.retagged": "neu getaggt",
"history.deleted": "gelöscht",
"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.allLanguages": "Alle Sprachen",
@@ -296,6 +304,14 @@ const STRINGS = {
"history.retagged": "retagged",
"history.deleted": "deleted",
"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.allLanguages": "All languages",
+10
View File
@@ -379,6 +379,16 @@
</form>
</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>
<script src="/i18n.js"></script>
<script src="/app.js"></script>