Files
wordarr/static/app.js
T

1364 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
let libraries = [];
const LANGUAGE_NAMES = { german: "Deutsch", english: "Englisch" };
async function api(path, opts = {}) {
const resp = await fetch(path, {
headers: { "Content-Type": "application/json" },
...opts,
});
if (!resp.ok) {
let msg = resp.statusText;
try { msg = (await resp.json()).detail || msg; } catch {}
const err = new Error(msg);
err.status = resp.status;
throw err;
}
return resp.json();
}
// create a request; on a duplicate warning (409) ask the user before retrying
async function createRequest(body) {
try {
return await api("/api/requests", { method: "POST", body: JSON.stringify(body) });
} catch (err) {
if (err.status === 409 && confirm(err.message + "\nTrotzdem anlegen?")) {
return api("/api/requests?allow_duplicate=true", {
method: "POST", body: JSON.stringify(body),
});
}
throw err;
}
}
function toast(msg, isError = false) {
const t = $("#toast");
t.textContent = msg;
t.className = isError ? "error" : "";
t.hidden = false;
setTimeout(() => (t.hidden = true), 4000);
}
function esc(s) {
const d = document.createElement("div");
d.textContent = s ?? "";
return d.innerHTML;
}
// last used library per media type (per-browser convenience)
function getLastLib(type) {
try { return localStorage.getItem("wordarr.lastLib." + type); } catch { return null; }
}
function setLastLib(type, id) {
try { localStorage.setItem("wordarr.lastLib." + type, String(id)); } catch {}
}
function libOptions(mediaType) {
const last = getLastLib(mediaType);
return libraries
.filter((l) => l.media_type === mediaType)
.map((l) => `<option value="${l.id}" ${String(l.id) === last ? "selected" : ""}>${esc(l.name)}</option>`)
.join("");
}
// ---- missing badge ----
async function refreshMissingBadge() {
try {
const reqs = await api("/api/requests?status=missing");
const badge = $("#missing-badge");
badge.textContent = reqs.length;
badge.hidden = reqs.length === 0;
} catch {}
}
// ---- navigation ----
$$("nav button").forEach((btn) =>
btn.addEventListener("click", () => {
$$("nav button").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
$$("main > section").forEach((s) => (s.hidden = true));
$("#view-" + btn.dataset.view).hidden = false;
if (btn.dataset.view === "missing") loadRequests("missing");
if (btn.dataset.view === "imported") loadRequests("imported");
if (btn.dataset.view === "settings") loadLibraries();
})
);
// ---- libraries ----
async function loadLibraries() {
libraries = await api("/api/libraries");
const tbody = $("#lib-table tbody");
tbody.innerHTML = libraries
.map(
(l) => `<tr>
<td>${esc(l.name)}</td><td>${esc(l.media_type)}</td><td>${esc(l.root_path)}</td>
<td class="mono">${esc(l.folder_template)}</td><td class="mono">${esc(l.file_template)}</td>
<td>${l.language ? esc(LANGUAGE_NAMES[l.language] || l.language) : "—"}</td>
<td class="row">
<button class="secondary" data-edit-lib="${l.id}">Bearbeiten</button>
<button class="danger" data-del-lib="${l.id}">Löschen</button>
</td>
</tr>`
)
.join("");
tbody.querySelectorAll("[data-edit-lib]").forEach((b) =>
b.addEventListener("click", () => startLibraryEdit(parseInt(b.dataset.editLib)))
);
tbody.querySelectorAll("[data-del-lib]").forEach((b) =>
b.addEventListener("click", async () => {
if (!confirm("Library löschen?")) return;
try {
await api("/api/libraries/" + b.dataset.delLib, { method: "DELETE" });
loadLibraries();
} catch (e) { toast(e.message, true); }
})
);
const filter = $("#missing-filter-library");
filter.innerHTML =
'<option value="">Alle Libraries</option>' +
libraries.map((l) => `<option value="${l.id}">${esc(l.name)}</option>`).join("");
}
function startLibraryEdit(id) {
const lib = libraries.find((l) => l.id === id);
if (!lib) return;
const form = $("#lib-form");
form.name.value = lib.name;
form.media_type.value = lib.media_type;
form.root_path.value = lib.root_path;
form.folder_template.value = lib.folder_template;
form.file_template.value = lib.file_template;
form.language.value = lib.language || "";
form.dataset.editId = lib.id;
$("#lib-form-title").textContent = `Library „${lib.name}“ bearbeiten`;
$("#lib-form-submit").textContent = "Speichern";
$("#lib-form-cancel").hidden = false;
form.scrollIntoView({ behavior: "smooth" });
}
function endLibraryEdit() {
const form = $("#lib-form");
form.reset();
delete form.dataset.editId;
$("#lib-form-title").textContent = "Neue Library";
$("#lib-form-submit").textContent = "Anlegen";
$("#lib-form-cancel").hidden = true;
}
$("#lib-form-cancel").addEventListener("click", endLibraryEdit);
$("#lib-form").addEventListener("submit", async (e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.target));
if (!data.folder_template) delete data.folder_template;
if (!data.file_template) delete data.file_template;
const editId = e.target.dataset.editId;
try {
if (editId) {
await api("/api/libraries/" + editId, { method: "PUT", body: JSON.stringify(data) });
toast("Library gespeichert — gilt für künftige Importe");
} else {
await api("/api/libraries", { method: "POST", body: JSON.stringify(data) });
toast("Library angelegt");
}
endLibraryEdit();
loadLibraries();
} catch (err) { toast(err.message, true); }
});
// path autocompletion for the library form
{
const pathInput = document.querySelector('#lib-form [name=root_path]');
const datalist = $("#path-suggestions");
let pathTimer;
pathInput.addEventListener("input", () => {
clearTimeout(pathTimer);
const value = pathInput.value;
if (!value.startsWith("/")) return;
pathTimer = setTimeout(async () => {
try {
const res = await api("/api/fs/browse?path=" + encodeURIComponent(value));
datalist.innerHTML = res.dirs.map((d) => `<option value="${esc(d)}">`).join("");
} catch {}
}, 150);
});
}
// ---- search & request ----
const EMPTY_HINTS = {
ebook: "Keine Treffer. Tipp: ISBN ohne Bindestriche oder Titel + Autor versuchen.",
audiobook: "Keine Treffer. Tipp: EAN/ISBN funktioniert bei Audible nicht — nach Titel/Autor suchen.",
comic: "Keine Treffer bei AniList (Manga). Westliche Comics per „Manuell anlegen“ erfassen.",
};
$("#search-type").addEventListener("change", () => ($("#search-results").innerHTML = ""));
$("#search-form").addEventListener("submit", async (e) => {
e.preventDefault();
const type = $("#search-type").value;
const q = $("#search-q").value;
const box = $("#search-results");
const searchBtn = $("#search-btn");
searchBtn.disabled = true;
searchBtn.innerHTML = '<span class="spinner"></span> Suche…';
try {
const language = $("#search-language").value;
const results = await api(
`/api/search?media_type=${type}&q=${encodeURIComponent(q)}&language=${language}`
);
if (!results.length) {
box.innerHTML = `<p class='muted'>${EMPTY_HINTS[type]}</p>`;
return;
}
const opts = libOptions(type);
box.innerHTML = results
.map(
(r, i) => `<div class="card">
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="" loading="lazy">` : '<div class="nocover">?</div>'}
<div class="card-body">
<strong>${esc(r.title)}</strong>
<span>${esc(r.authors)}</span>
${r.narrator ? `<span class="muted">🎙 ${esc(r.narrator)}</span>` : ""}
${r.series && r.media_type ? `<span class="muted">📚 ${esc(r.series)}${r.volume != null ? " #" + r.volume : ""}</span>` : ""}
<span class="muted">${r.year ?? ""} ${r.language ? "· " + esc(LANGUAGE_NAMES[r.language] || r.language) : ""} ${r.external_id ? "· " + esc(r.external_id) : ""}</span>
${type === "comic" ? `<input type="number" min="0" placeholder="Band" class="volume" data-vol="${i}">` : ""}
<div class="row">
<select data-lib="${i}">${opts || "<option value=''>— keine Library —</option>"}</select>
<button data-req="${i}">Anfragen</button>
${r.series_id ? `<button class="secondary" data-series="${i}">Ganze Serie…</button>` : ""}
</div>
</div>
</div>`
)
.join("");
box.querySelectorAll("[data-series]").forEach((btn) =>
btn.addEventListener("click", () => openSeriesDialog(results[btn.dataset.series]))
);
box.querySelectorAll("[data-req]").forEach((btn) =>
btn.addEventListener("click", async () => {
const i = btn.dataset.req;
const r = results[i];
const libId = box.querySelector(`[data-lib="${i}"]`).value;
if (!libId) { toast("Erst eine Library für diesen Typ anlegen (Tab Libraries)", true); return; }
const volInput = box.querySelector(`[data-vol="${i}"]`);
try {
await createRequest({
library_id: parseInt(libId),
title: r.title, authors: r.authors, narrator: r.narrator || "",
external_id: r.external_id,
year: r.year, series: r.series, cover_url: r.cover_url,
volume: volInput && volInput.value ? parseInt(volInput.value) : (r.volume ?? null),
});
setLastLib(type, libId);
btn.textContent = "✓ Angefragt";
btn.disabled = true;
refreshMissingBadge();
} catch (err) { toast(err.message, true); }
})
);
} catch (err) {
box.innerHTML = "";
toast(err.message, true);
} finally {
searchBtn.disabled = false;
searchBtn.textContent = "Suchen";
}
});
// ---- request a whole series ----
let seriesEpisodes = [];
function renderSeriesList() {
const from = parseInt($("#series-from").value);
const to = parseInt($("#series-to").value);
const inRange = (e) =>
(isNaN(from) || (e.volume != null && e.volume >= from)) &&
(isNaN(to) || (e.volume != null && e.volume <= to));
let previous = null;
$("#series-list").innerHTML = seriesEpisodes
.map((e, i) => {
// make holes in the numbering visible - the crawl cannot reach every episode
let gap = "";
if (e.volume != null && previous != null && e.volume > previous + 1) {
const missing = e.volume - previous - 1;
gap = `<div class="gap">… ${missing} Folge${missing > 1 ? "n" : ""} nicht bei Audible gefunden (${previous + 1}${e.volume - 1})</div>`;
}
if (e.volume != null) previous = e.volume;
return `${gap}<label>
<input type="checkbox" data-ep="${i}" ${inRange(e) ? "checked" : ""}>
<span class="num">${e.volume ?? "—"}</span>
<span>${esc(e.title)}</span>
</label>`;
})
.join("");
updateSeriesCount();
}
function seriesChecked() {
return [...$("#series-list").querySelectorAll("input:checked")].map(
(c) => seriesEpisodes[c.dataset.ep]
);
}
function updateSeriesCount() {
const n = seriesChecked().length;
const btn = $("#series-submit");
btn.textContent = n ? `${n} anfragen` : "Anfragen";
btn.disabled = n === 0;
}
async function openSeriesDialog(result) {
const dlg = $("#series-dialog");
$("#series-title").textContent = result.series || "Serie";
$("#series-info").innerHTML = '<span class="spinner"></span> Folgen werden gesucht…';
$("#series-list").innerHTML = "";
$("#series-from").value = "";
$("#series-to").value = "";
$("#series-library").innerHTML = libOptions("audiobook") || "<option value=''>— keine Library —</option>";
$("#series-submit").disabled = true;
dlg.showModal();
try {
seriesEpisodes = await api(
`/api/search/series?series_id=${encodeURIComponent(result.series_id)}` +
`&title=${encodeURIComponent(result.series || result.title)}`
);
} catch (err) {
$("#series-info").textContent = err.message;
return;
}
const numbered = seriesEpisodes.filter((e) => e.volume != null);
$("#series-info").textContent =
`${seriesEpisodes.length} Folgen gefunden` +
(numbered.length ? ` (Nr. ${numbered[0].volume}${numbered[numbered.length - 1].volume})` : "") +
". Audible hat keine Serien-Abfrage — einzelne Folgen können fehlen.";
renderSeriesList();
}
$("#series-from").addEventListener("input", renderSeriesList);
$("#series-to").addEventListener("input", renderSeriesList);
$("#series-list").addEventListener("change", updateSeriesCount);
$("#series-all").addEventListener("click", () => {
$("#series-list").querySelectorAll("input").forEach((c) => (c.checked = true));
updateSeriesCount();
});
$("#series-none").addEventListener("click", () => {
$("#series-list").querySelectorAll("input").forEach((c) => (c.checked = false));
updateSeriesCount();
});
$("#series-cancel").addEventListener("click", () => $("#series-dialog").close());
$("#series-submit").addEventListener("click", async () => {
const libId = $("#series-library").value;
if (!libId) { toast("Erst eine Audiobook-Library anlegen (Tab Libraries)", true); return; }
const items = seriesChecked().map((e) => ({
title: e.title, authors: e.authors, narrator: e.narrator || "",
external_id: e.external_id, year: e.year, series: e.series,
volume: e.volume, cover_url: e.cover_url,
}));
const btn = $("#series-submit");
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Lege an…';
try {
const res = await api("/api/requests/bulk-items", {
method: "POST",
body: JSON.stringify({ library_id: parseInt(libId), items }),
});
setLastLib("audiobook", libId);
toast(
`${res.created.length} Anfragen angelegt` +
(res.skipped ? `, ${res.skipped} bereits vorhanden` : "")
);
$("#series-dialog").close();
refreshMissingBadge();
} catch (err) {
toast(err.message, true);
} finally {
btn.disabled = false;
updateSeriesCount();
}
});
// ---- manual request ----
$("#manual-btn").addEventListener("click", () => {
const dlg = $("#manual-dialog");
const typeSel = dlg.querySelector("[name=media_type]");
const updateLibs = () => {
$("#manual-library").innerHTML = libOptions(typeSel.value) || "<option value=''>— keine Library —</option>";
};
typeSel.onchange = updateLibs;
updateLibs();
dlg.showModal();
});
$("#manual-form").addEventListener("submit", async (e) => {
if (e.submitter && e.submitter.value === "cancel") return;
const data = Object.fromEntries(new FormData(e.target));
if (!data.library_id) { toast("Keine Library gewählt", true); return; }
try {
if (data.volume_to) {
if (!data.volume) {
toast("Für Bulk bitte Start-Band angeben", true);
return;
}
const created = await api("/api/requests/bulk", {
method: "POST",
body: JSON.stringify({
library_id: parseInt(data.library_id),
series: data.series || data.title, authors: data.authors,
narrator: data.narrator || "",
volume_from: parseInt(data.volume),
volume_to: parseInt(data.volume_to),
year: data.year ? parseInt(data.year) : null,
}),
});
toast(`${created.length} Anfragen angelegt`);
} else {
await createRequest({
library_id: parseInt(data.library_id),
title: data.title, authors: data.authors, series: data.series,
narrator: data.narrator || "",
external_id: data.external_id,
year: data.year ? parseInt(data.year) : null,
volume: data.volume ? parseInt(data.volume) : null,
});
toast("Anfrage angelegt");
}
setLastLib(data.media_type, data.library_id);
e.target.reset();
refreshMissingBadge();
} catch (err) { toast(err.message, true); }
});
// ---- missing / imported lists ----
let currentRequests = { missing: [], imported: [] };
// view mode (Liste / kleine / große Symbole), shared by both lists
function getViewMode() {
try { return localStorage.getItem("wordarr.viewMode") || "small"; } catch { return "small"; }
}
function applyViewMode(mode) {
try { localStorage.setItem("wordarr.viewMode", mode); } catch {}
["#missing-list", "#imported-list"].forEach((sel) => {
const box = $(sel);
box.classList.remove("view-list", "view-small", "view-large");
box.classList.add("view-" + mode);
});
$$(".view-toggle button").forEach((b) =>
b.classList.toggle("active", b.dataset.viewmode === mode)
);
}
$$(".view-toggle button").forEach((b) =>
b.addEventListener("click", () => applyViewMode(b.dataset.viewmode))
);
applyViewMode(getViewMode());
function requestMatchesFilter(r, text) {
if (!text) return true;
const hay = `${r.title} ${r.authors} ${r.series} ${r.narrator}`.toLowerCase();
return hay.includes(text.toLowerCase());
}
async function loadRequests(status) {
const params = new URLSearchParams({ status });
if (status === "missing") {
const t = $("#missing-filter-type").value;
const l = $("#missing-filter-library").value;
if (t) params.set("media_type", t);
if (l) params.set("library_id", l);
}
currentRequests[status] = await api("/api/requests?" + params);
renderRequests(status);
if (status === "missing") refreshMissingBadge();
}
function renderRequests(status) {
const text = status === "missing" ? $("#missing-search").value : $("#imported-search").value;
const reqs = currentRequests[status].filter((r) => requestMatchesFilter(r, text));
const box = status === "missing" ? $("#missing-list") : $("#imported-list");
const bulkBtn = status === "missing" ? $("#missing-bulk-delete") : $("#imported-bulk-delete");
if (!reqs.length) {
box.innerHTML = "<p class='muted'>Nichts hier.</p>";
bulkBtn.hidden = true;
return;
}
box.innerHTML = reqs
.map(
(r) => `<div class="card clickable" data-detail="${r.id}">
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="" loading="lazy">` : '<div class="nocover">?</div>'}
<div class="card-body">
<strong>${esc(r.title)}${r.volume != null ? " · Band " + r.volume : ""}</strong>
<span>${esc(r.authors)}</span>
${r.narrator ? `<span class="muted">🎙 ${esc(r.narrator)}</span>` : ""}
<span class="muted">${esc(r.media_type)}${esc(r.library_name)}</span>
${status === "imported" ? `<span class="muted mono">${esc(r.imported_path)}</span>` : ""}
</div>
<input type="checkbox" class="card-check" data-sel-req="${r.id}">
</div>`
)
.join("");
box.querySelectorAll("[data-detail]").forEach((card) =>
card.addEventListener("click", (ev) => {
if (ev.target.closest("input,button,select,a")) return;
openDetail(parseInt(card.dataset.detail), status);
})
);
const updateBulkBtn = () => {
const n = box.querySelectorAll("[data-sel-req]:checked").length;
bulkBtn.hidden = n === 0;
bulkBtn.textContent = `Ausgewählte entfernen (${n})`;
};
box.querySelectorAll("[data-sel-req]").forEach((c) =>
c.addEventListener("change", updateBulkBtn)
);
updateBulkBtn();
}
async function bulkDeleteRequests(status) {
const box = status === "missing" ? $("#missing-list") : $("#imported-list");
const ids = [...box.querySelectorAll("[data-sel-req]:checked")].map((c) => c.dataset.selReq);
if (!ids.length) return;
const hint = status === "imported"
? `${ids.length} Eintrag/Einträge entfernen? Die importierten Dateien in der Library bleiben erhalten.`
: `${ids.length} Anfrage(n) entfernen?`;
if (!confirm(hint)) return;
for (const id of ids) {
try { await api("/api/requests/" + id, { method: "DELETE" }); }
catch (err) { toast(err.message, true); }
}
toast(`${ids.length} entfernt`);
loadRequests(status);
}
$("#imported-bulk-delete").addEventListener("click", () => bulkDeleteRequests("imported"));
function selectAllRequests(status) {
const box = status === "missing" ? $("#missing-list") : $("#imported-list");
const checks = [...box.querySelectorAll("[data-sel-req]")];
const allChecked = checks.length && checks.every((c) => c.checked);
checks.forEach((c) => (c.checked = !allChecked));
if (checks.length) checks[0].dispatchEvent(new Event("change"));
}
$("#missing-select-all").addEventListener("click", () => selectAllRequests("missing"));
$("#imported-select-all").addEventListener("click", () => selectAllRequests("imported"));
$("#missing-filter-type").addEventListener("change", () => loadRequests("missing"));
$("#missing-filter-library").addEventListener("change", () => loadRequests("missing"));
$("#missing-search").addEventListener("input", () => renderRequests("missing"));
$("#imported-search").addEventListener("input", () => renderRequests("imported"));
$("#missing-bulk-delete").addEventListener("click", () => bulkDeleteRequests("missing"));
// ---- request detail dialog ----
let detailRequest = null;
function openDetail(id, status) {
const r = currentRequests[status].find((x) => x.id === id);
if (!r) return;
detailRequest = r;
const form = $("#detail-form");
form.title.value = r.title;
form.authors.value = r.authors || "";
form.narrator.value = r.narrator || "";
form.series.value = r.series || "";
form.volume.value = r.volume ?? "";
form.year.value = r.year ?? "";
form.external_id.value = r.external_id || "";
$("#detail-library").innerHTML = libraries
.filter((l) => l.media_type === r.media_type)
.map((l) => `<option value="${l.id}" ${l.id === r.library_id ? "selected" : ""}>${esc(l.name)}</option>`)
.join("");
$("#detail-title").textContent = r.title;
$("#detail-status").textContent =
`${r.media_type} · ${r.status === "missing" ? "fehlt noch" : "importiert"}`;
$("#detail-path").textContent = r.imported_path || "";
const img = $("#detail-cover");
img.hidden = !r.cover_url;
$("#detail-nocover").hidden = !!r.cover_url;
if (r.cover_url) img.src = r.cover_url;
$("#detail-retag").hidden = !(r.status === "imported" && r.media_type === "audiobook");
$("#detail-relocate").hidden = r.status !== "imported";
$("#detail-dialog").showModal();
}
async function saveDetail(form) {
await api("/api/requests/" + detailRequest.id, {
method: "PUT",
body: JSON.stringify({
library_id: parseInt(form.library_id.value),
title: form.title.value,
authors: form.authors.value,
narrator: form.narrator.value,
series: form.series.value,
volume: form.volume.value ? parseInt(form.volume.value) : null,
year: form.year.value ? parseInt(form.year.value) : null,
external_id: form.external_id.value,
cover_url: detailRequest.cover_url || "",
}),
});
}
// after correcting series/title/author the files are still filed under the old
// name - this re-applies the library's scheme to what is already on disk
$("#detail-relocate").addEventListener("click", async () => {
if (!detailRequest) return;
const btn = $("#detail-relocate");
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Lege ab…';
try {
await saveDetail($("#detail-form"));
const res = await api(`/api/requests/${detailRequest.id}/relocate`, { method: "POST" });
toast(res.moved ? `Neu abgelegt: ${res.dest}` : "Liegt bereits richtig");
$("#detail-dialog").close();
loadRequests(detailRequest.status);
} catch (err) { toast(err.message, true); }
finally {
btn.disabled = false;
btn.textContent = "Speichern & neu ablegen";
}
});
$("#detail-retag").addEventListener("click", async () => {
if (!detailRequest) return;
const btn = $("#detail-retag");
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Tagge…';
try {
await saveDetail($("#detail-form"));
const res = await api(`/api/requests/${detailRequest.id}/retag`, { method: "POST" });
toast(`${res.files} Datei(en) neu getaggt`);
$("#detail-dialog").close();
loadRequests(detailRequest.status);
} catch (err) { toast(err.message, true); }
finally {
btn.disabled = false;
btn.textContent = "Speichern & neu taggen";
}
});
$("#detail-form").addEventListener("submit", async (e) => {
if (e.submitter && e.submitter.value === "cancel") return;
if (!detailRequest) return;
try {
await saveDetail(e.target);
toast("Anfrage gespeichert");
loadRequests(detailRequest.status);
} catch (err) { toast(err.message, true); }
});
// ---- import ----
let scanItems = [];
let missingReqs = [];
let importedReqs = [];
let skippedConflicts = 0;
let orphanedIds = new Set(); // imported, but their files are gone from the library
let viewIdx = []; // indices into scanItems after filter/sort
let importPage = 0;
const IMPORT_PAGE_SIZE = 25;
$("#scan-btn").addEventListener("click", async () => {
const scanBtn = $("#scan-btn");
scanBtn.disabled = true;
$("#scan-info").innerHTML = '<span class="spinner"></span> Scanne…';
try {
const split = $("#split-dirs").checked;
const [scan, reqs, done] = await Promise.all([
api("/api/import/scan?split_dirs=" + split),
api("/api/requests?status=missing"),
api("/api/requests?status=imported"),
]);
clearTimeout(cleanupTimer);
scanItems = scan.items;
// selection state lives here, not in the DOM, so it survives paging/filtering
scanItems.forEach((item) => {
item.request_id = item.suggested_request_id;
item.checked = !!item.suggested_request_id;
});
orphanedIds = new Set(scan.orphaned_request_ids || []);
// an imported title whose files vanished is selectable like an open one
missingReqs = [...reqs, ...done.filter((r) => orphanedIds.has(r.id))];
importedReqs = done.filter((r) => !orphanedIds.has(r.id));
importPage = 0;
$("#scan-info").textContent = `${scan.items.length} Kandidat(en) in ${scan.download_dir}`;
$("#import-toolbar").hidden = scanItems.length === 0;
renderImportTable();
} catch (err) {
$("#scan-info").textContent = "";
toast(err.message, true);
} finally {
scanBtn.disabled = false;
}
});
function applyImportView() {
const text = $("#import-filter-text").value.toLowerCase();
const mode = $("#import-filter-mode").value;
const sort = $("#import-sort").value;
viewIdx = scanItems
.map((_, i) => i)
.filter((i) => {
const item = scanItems[i];
if (text && !(`${item.name} ${item.rel_dir || ""}`.toLowerCase().includes(text))) return false;
if (mode === "suggested" && !item.suggested_request_id) return false;
if (mode === "unassigned" && item.request_id) return false;
if (mode === "conflict" && !item.conflict) return false;
return true;
});
if (sort === "score") viewIdx.sort((a, b) => scanItems[b].score - scanItems[a].score);
if (sort === "name") viewIdx.sort((a, b) => scanItems[a].name.localeCompare(scanItems[b].name));
}
function scoreCell(item) {
if (!item.suggested_request_id) return '<td class="score-none">—</td>';
const cls = item.score >= 80 ? "score-hi" : item.score >= 50 ? "score-mid" : "score-lo";
return `<td class="${cls}">${item.score}</td>`;
}
function renderImportTable() {
applyImportView();
const table = $("#import-table");
const tbody = table.querySelector("tbody");
if (!viewIdx.length) {
table.hidden = true;
$("#import-btn").hidden = scanItems.length === 0;
$("#import-pager").hidden = true;
tbody.innerHTML = "";
if (scanItems.length) {
$("#import-page-info").textContent = "";
}
return;
}
const pages = Math.ceil(viewIdx.length / IMPORT_PAGE_SIZE);
importPage = Math.min(importPage, pages - 1);
const start = importPage * IMPORT_PAGE_SIZE;
const pageIdx = viewIdx.slice(start, start + IMPORT_PAGE_SIZE);
tbody.innerHTML = pageIdx
.map((i) => {
const item = scanItems[i];
const option = (r, suffix = "") =>
`<option value="${r.id}" ${r.id === item.request_id ? "selected" : ""}>
${esc(r.title)}${esc(r.authors)} (${esc(r.library_name)})${suffix}</option>`;
const opts = missingReqs
.filter((r) => r.media_type === item.media_type)
.map((r) => option(r, orphanedIds.has(r.id) ? " ↺" : ""))
.join("");
// already imported audiobooks can take further parts (a late CD, or one
// that failed while its siblings went through)
const appendOpts = importedReqs
.filter((r) => r.media_type === item.media_type && r.imported_path)
.map((r) => option(r, " ↩︎"))
.join("");
return `<tr class="${item.importState ? "row-" + item.importState : item.conflict ? "row-conflict" : ""}">
<td><input type="checkbox" data-check="${i}" ${item.checked ? "checked" : ""}></td>
<td class="mono">
${esc(item.name)}${item.is_dir ? " 📁" : ""}
${item.parts ? `<span class="tag">${item.parts.length} Teile · ${item.files.length} Dateien</span>` : ""}
${!item.parts && item.maybe_separate
? `<span class="tag warn" title="Die ${item.files.length} Dateien tragen verschiedene Titel und sind je groß genug für ein ganzes Buch — mit ✂️ aufteilen">${item.files.length} Titel?</span>`
: ""}
${item.rel_dir ? `<div class="subpath">${esc(item.rel_dir)}/</div>` : ""}
</td>
<td>${esc(item.media_type)}</td>
<td class="row">
<select data-select="${i}"><option value="">— überspringen —</option>${opts}
${appendOpts ? `<optgroup label="Bereits importiert — Teile anhängen">${appendOpts}</optgroup>` : ""}
</select>
<button class="secondary" data-quick="${i}" title="Metadaten suchen und Anfrage direkt verbinden">🔍</button>
${item.parts || (item.is_dir && item.files.length > 1)
? `<button class="secondary" data-split="${i}" title="${item.parts ? "Zusammenfassung wieder auflösen" : "Ordner in einzelne Titel aufteilen"}">✂️</button>`
: ""}
</td>
${item.importState ? importStateCell(item)
: item.conflict ? '<td class="state conflict" title="Mehrere Einträge zeigen auf dieselbe Anfrage">⚠ Konflikt</td>'
: scoreCell(item)}
</tr>`;
})
.join("");
table.hidden = false;
$("#import-btn").hidden = false;
const showsState = scanItems.some((it) => it.importState);
table.querySelector("thead th:last-child").textContent = showsState ? "Status" : "Score";
tbody.querySelectorAll("[data-quick]").forEach((b) =>
b.addEventListener("click", () => openQuickDialog(parseInt(b.dataset.quick)))
);
tbody.querySelectorAll("[data-split]").forEach((b) =>
b.addEventListener("click", () => splitItem(parseInt(b.dataset.split)))
);
tbody.querySelectorAll("[data-check]").forEach((c) =>
c.addEventListener("change", () => {
scanItems[c.dataset.check].checked = c.checked;
updateImportPageInfo();
})
);
tbody.querySelectorAll("[data-select]").forEach((s) =>
s.addEventListener("change", () => {
const item = scanItems[s.dataset.select];
item.request_id = s.value ? parseInt(s.value) : null;
item.checked = !!item.request_id;
const check = tbody.querySelector(`[data-check="${s.dataset.select}"]`);
if (check) check.checked = item.checked;
updateImportPageInfo();
})
);
$("#import-pager").hidden = false;
$("#import-prev").disabled = importPage === 0;
$("#import-next").disabled = importPage >= pages - 1;
updateImportPageInfo(pages);
}
function importStateCell(item) {
if (item.importState === "running") {
return '<td class="state running"><span class="spinner"></span> läuft…</td>';
}
if (item.importState === "done") return '<td class="state done">✓ importiert</td>';
return `<td class="state failed" title="${esc(item.importError)}">✗ Fehler</td>`;
}
function updateImportPageInfo(pages) {
pages = pages ?? Math.max(1, Math.ceil(viewIdx.length / IMPORT_PAGE_SIZE));
const selected = scanItems.filter((it) => it.checked && it.request_id).length;
const filtered = viewIdx.length !== scanItems.length ? ` (gefiltert: ${viewIdx.length})` : "";
$("#import-page-info").textContent =
`Seite ${importPage + 1}/${pages} · ${scanItems.length} Einträge${filtered} · ${selected} ausgewählt`;
}
$("#import-prev").addEventListener("click", () => { importPage--; renderImportTable(); });
$("#import-next").addEventListener("click", () => { importPage++; renderImportTable(); });
$("#import-filter-text").addEventListener("input", () => { importPage = 0; renderImportTable(); });
$("#import-filter-mode").addEventListener("change", () => { importPage = 0; renderImportTable(); });
$("#import-sort").addEventListener("change", () => { importPage = 0; renderImportTable(); });
$("#import-select-suggested").addEventListener("click", () => {
applyImportView();
viewIdx.forEach((i) => {
const item = scanItems[i];
if (item.suggested_request_id) {
item.request_id = item.request_id || item.suggested_request_id;
item.checked = true;
}
});
renderImportTable();
});
$("#import-select-page").addEventListener("click", () => {
applyImportView();
const start = importPage * IMPORT_PAGE_SIZE;
const pageIdx = viewIdx.slice(start, start + IMPORT_PAGE_SIZE);
// exclusive on purpose: "import what I see", not "add to what was selected"
scanItems.forEach((it) => (it.checked = false));
let n = 0;
pageIdx.forEach((i) => {
const item = scanItems[i];
const req = item.request_id || item.suggested_request_id;
if (!req) return;
item.request_id = req;
item.checked = true;
n += 1;
});
renderImportTable();
toast(n ? `${n} Einträge dieser Seite ausgewählt` : "Auf dieser Seite gibt es keine Zuordnung", !n);
});
$("#import-deselect-all").addEventListener("click", () => {
applyImportView();
viewIdx.forEach((i) => (scanItems[i].checked = false));
renderImportTable();
});
// ---- multi-part episodes: import several entries as one audiobook ----
// e.g. "100 - Toteninsel Teil 1/2/3", which Audible lists as a single title
function commonPrefix(names) {
let prefix = names[0];
for (const n of names.slice(1)) {
let i = 0;
while (i < prefix.length && i < n.length && prefix[i] === n[i]) i++;
prefix = prefix.slice(0, i);
}
// drop a dangling part marker: "100 - Toteninsel Teil " -> "100 - Toteninsel"
return prefix.replace(/[\s._\-–—:]*(?:teil|part|cd|disc|folge)?[\s._\-–—:]*$/i, "").trim();
}
$("#import-merge").addEventListener("click", () => {
const chosen = scanItems.filter((it) => it.checked);
if (chosen.length < 2) {
toast("Mindestens zwei Einträge auswählen", true);
return;
}
if (new Set(chosen.map((it) => it.media_type)).size > 1) {
toast("Nur Einträge desselben Typs lassen sich zusammenfassen", true);
return;
}
const parents = new Set(chosen.map((it) => it.rel_dir));
if (parents.size > 1) {
toast("Die Einträge liegen in verschiedenen Ordnern", true);
return;
}
// parts may share no prefix at all ("A - Sphinx", "B - Volk"): then the
// folder holding them carries the real title ("100 - Toteninsel")
const parentName = (chosen[0].rel_dir || "").split("/").filter(Boolean).pop();
const name =
commonPrefix(chosen.map((it) => it.name)) || parentName || chosen[0].name;
const merged = {
path: chosen[0].path.slice(0, chosen[0].path.length - chosen[0].name.length - 1) || chosen[0].path,
name,
rel_dir: chosen[0].rel_dir,
media_type: chosen[0].media_type,
is_dir: true,
files: chosen.flatMap((it) => it.files),
checked: true,
request_id: chosen.find((it) => it.request_id)?.request_id ?? null,
suggested_request_id: chosen.find((it) => it.suggested_request_id)?.suggested_request_id ?? null,
score: Math.max(...chosen.map((it) => it.score ?? 0)),
parts: chosen,
};
const first = scanItems.indexOf(chosen[0]);
scanItems = scanItems.filter((it) => !chosen.includes(it));
scanItems.splice(first, 0, merged);
toast(`${chosen.length} Einträge als „${name}" zusammengefasst (${merged.files.length} Dateien)`);
renderImportTable();
});
function splitItem(i) {
const item = scanItems[i];
if (item.parts) { // undo a merge
scanItems.splice(i, 1, ...item.parts);
} else if (item.is_dir && item.files.length > 1) {
// a folder holding one book per file (a series bought as single titles):
// turn every file into its own entry so each can get its own request
const parts = item.files.map((f) => ({
path: f,
name: f.split("/").pop().replace(/\.[^.]+$/, ""),
rel_dir: item.rel_dir ? `${item.rel_dir}/${item.name}` : item.name,
media_type: item.media_type,
is_dir: false,
files: [f],
checked: false,
request_id: null,
suggested_request_id: null,
score: null,
}));
scanItems.splice(i, 1, ...parts);
toast(`„${item.name}" in ${parts.length} Einzeltitel aufgeteilt`);
} else {
return;
}
renderImportTable();
}
// ---- quick request from a scanned file ----
let quickItemIndex = null;
let quickEpisode = null;
let quickPicked = null; // metadata hit the manual fields were filled from
let lastSeries = "";
// "017 - Titel", "Folge 17: Titel", "[003] Titel" -> episode number + rest.
// A bare number without a separator ("100 Stunden") or without a leading zero is
// left alone: there it is part of the title, not a numbering.
const EPISODE_PATTERNS = [
/^\s*(?:folge|teil|band|nr\.?)\s*(\d{1,4})\s*(?:[-–—._:]+\s*|\s+)/i, // "Folge 124: Titel"
/^\s*[\[(](\d{1,4})[\])]\s*(?:[-–—._:]+\s*|\s+)/, // "[003] Titel"
/^\s*(0\d{1,3})\s*(?:[-–—._:]+\s*|\s+)/, // "001 Titel"
/^\s*(\d{1,3})\s*[-–—._:]+\s*/, // "17 - Titel"
];
function splitEpisodeNumber(name) {
for (const re of EPISODE_PATTERNS) {
const m = name.match(re);
if (!m) continue;
const rest = name.slice(m[0].length).trim();
if (!rest) break; // the number *is* the name
return { number: parseInt(m[1], 10), rest };
}
return { number: null, rest: name };
}
function cleanFileName(name) {
return name
.replace(/[._\-\[\]()]+/g, " ")
.replace(/\b(mp3|m4b|flac|epub|pdf|cbz|cbr|retail|unabridged|kompl.*)\b/gi, " ")
.replace(/\s+/g, " ")
.trim();
}
function openQuickDialog(i) {
quickItemIndex = i;
const item = scanItems[i];
// the episode number wrecks the Audible keyword search, so it is kept out of
// the query and used as the volume instead
const { number, rest } = splitEpisodeNumber(item.name);
quickEpisode = number;
$("#quick-file").textContent = (item.rel_dir ? item.rel_dir + "/" : "") + item.name;
$("#quick-episode").textContent = number != null ? `Folge ${number} — nicht mitgesucht` : "";
$("#quick-q").value = cleanFileName(rest);
quickPicked = null;
$("#quick-m-title").value = cleanFileName(rest);
$("#quick-m-authors").value = "";
$("#quick-m-volume").value = number ?? "";
$("#quick-m-series").value = lastSeries;
$("#quick-based-on").hidden = true;
$("#quick-manual-summary").textContent = "Nicht bei Audible? Manuell anlegen";
$("#quick-manual").open = false;
$("#quick-library").innerHTML =
libOptions(item.media_type) || "<option value=''>— keine Library —</option>";
$("#quick-results").innerHTML = "";
renderQuickOpenRequests(item);
$("#quick-dialog").showModal();
runQuickSearch();
}
// offer linking to similar already-open requests instead of creating a duplicate
function renderQuickOpenRequests(item) {
const box = $("#quick-open-requests");
const tokens = cleanFileName(item.name).toLowerCase().split(" ").filter((t) => t.length > 2);
const matches = missingReqs
.filter((r) => r.media_type === item.media_type)
.map((r) => {
const hay = `${r.title} ${r.authors} ${r.series} ${r.narrator}`.toLowerCase();
return { r, hits: tokens.filter((t) => hay.includes(t)).length };
})
.filter((m) => m.hits >= 2)
.sort((a, b) => b.hits - a.hits)
.slice(0, 3);
if (!matches.length) { box.innerHTML = ""; return; }
box.innerHTML =
'<p class="muted">Passende offene Requests:</p>' +
matches
.map(
({ r }) => `<div class="row open-req">
<span>${esc(r.title)}${r.volume != null ? " · Band " + r.volume : ""}${esc(r.authors)} (${esc(r.library_name)})</span>
<button class="secondary" data-link-req="${r.id}">Verbinden</button>
</div>`
)
.join("");
box.querySelectorAll("[data-link-req]").forEach((b) =>
b.addEventListener("click", () => {
const i = quickItemIndex;
scanItems[i].request_id = parseInt(b.dataset.linkReq);
scanItems[i].checked = true;
renderImportTable();
$("#quick-dialog").close();
toast("Mit bestehendem Request verbunden");
})
);
}
async function runQuickSearch() {
const item = scanItems[quickItemIndex];
const box = $("#quick-results");
box.innerHTML = "<p class='muted'>Suche läuft…</p>";
try {
// the target library decides the language: an "english" library should not
// offer the German edition of the same book
const lib = libraries.find((l) => String(l.id) === $("#quick-library").value);
const results = await api(
`/api/search?media_type=${item.media_type}&q=${encodeURIComponent($("#quick-q").value)}` +
`&language=${lib?.language || ""}`
);
if (!results.length) {
box.innerHTML = `<p class='muted'>${EMPTY_HINTS[item.media_type]}</p>`;
return;
}
// one compact row per hit: cover, title + a single meta line, action right
box.innerHTML = results
.map((r, j) => {
const meta = [
r.authors,
r.narrator ? "🎙 " + r.narrator : "",
r.series ? `📚 ${r.series}${r.volume != null ? " #" + r.volume : ""}` : "",
r.year ?? "",
LANGUAGE_NAMES[r.language] || r.language,
].filter(Boolean).map(esc).join(" · ");
return `<div class="card">
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="" loading="lazy">` : '<div class="nocover">?</div>'}
<div class="card-body">
<strong>${esc(r.title)}</strong>
<span class="muted">${meta}</span>
</div>
<button data-edit="${j}" class="secondary" title="Felder vor dem Anlegen anpassen (z.B. Serie)">✎</button>
<button data-pick="${j}" title="Anfrage anlegen und mit diesem Eintrag verbinden">Verbinden</button>
</div>`;
})
.join("");
box.querySelectorAll("[data-pick]").forEach((btn) =>
btn.addEventListener("click", () => pickQuickResult(results[btn.dataset.pick]))
);
box.querySelectorAll("[data-edit]").forEach((btn) =>
btn.addEventListener("click", () => editBeforeRequest(results[btn.dataset.edit]))
);
} catch (err) {
box.innerHTML = "";
toast(err.message, true);
}
}
$("#quick-search-form").addEventListener("submit", (e) => {
e.preventDefault();
runQuickSearch();
});
$("#quick-close").addEventListener("click", () => $("#quick-dialog").close());
// create the request and wire it to the scanned entry the dialog was opened for
async function requestAndConnect(body) {
const libId = $("#quick-library").value;
if (!libId) { toast("Erst eine Library für diesen Typ anlegen", true); return; }
const i = quickItemIndex;
try {
const req = await createRequest({ library_id: parseInt(libId), ...body });
setLastLib(scanItems[i].media_type, libId);
missingReqs.push(req);
scanItems[i].request_id = req.id;
scanItems[i].checked = true;
renderImportTable();
refreshMissingBadge();
$("#quick-dialog").close();
toast(`„${req.title}" angefragt und verbunden`);
} catch (err) { toast(err.message, true); }
}
function pickQuickResult(r) {
return requestAndConnect({
title: r.title, authors: r.authors, narrator: r.narrator || "",
external_id: r.external_id, volume: r.volume ?? quickEpisode,
year: r.year, series: r.series, cover_url: r.cover_url,
});
}
// take a hit into the fields so title/author/series can be corrected before the
// request is created - Audible files Harry Potter under "Wizarding World", and
// the series ends up in the folder name
function editBeforeRequest(r) {
quickPicked = r;
$("#quick-m-title").value = r.title || "";
$("#quick-m-authors").value = r.authors || "";
$("#quick-m-series").value = r.series || "";
$("#quick-m-volume").value = r.volume ?? quickEpisode ?? "";
$("#quick-based-on").textContent =
`Übernommen von Audible: „${r.title}" — Sprecher, Cover, Jahr und ASIN bleiben erhalten.`;
$("#quick-based-on").hidden = false;
$("#quick-manual-summary").textContent = "Felder anpassen";
$("#quick-manual").open = true;
$("#quick-m-title").focus();
}
$("#quick-m-submit").addEventListener("click", () => {
const title = $("#quick-m-title").value.trim();
if (!title) { toast("Titel angeben", true); return; }
const volume = $("#quick-m-volume").value;
const base = quickPicked
? { narrator: quickPicked.narrator || "", external_id: quickPicked.external_id,
year: quickPicked.year, cover_url: quickPicked.cover_url }
: {};
return requestAndConnect({
...base,
title,
authors: $("#quick-m-authors").value.trim(),
series: (lastSeries = $("#quick-m-series").value.trim()),
volume: volume ? parseInt(volume) : null,
});
});
// ---- import execution (batched, with progress) ----
const IMPORT_BATCH_SIZE = 20;
// finished rows disappear after a while: the result list below keeps the record,
// and a shrinking table makes the remaining work obvious
const IMPORT_DONE_CLEANUP_MS = 15000;
let cleanupTimer = null;
// several entries on one request means one audiobook split across folders:
// importing them one by one only imports the first and fails the rest
function groupByRequest(chosen) {
const byRequest = new Map();
for (const item of chosen) {
const group = byRequest.get(item.request_id);
if (group) group.push(item);
else byRequest.set(item.request_id, [item]);
}
return [...byRequest.values()];
}
function buildImportGroups(groups) {
return groups.map((group) => ({
parts: group,
item: {
path: group.length > 1 ? sharedParent(group) : group[0].path,
is_dir: group.length > 1 ? true : group[0].is_dir,
files: group.flatMap((it) => it.files),
request_id: group[0].request_id,
append: importedReqs.some((r) => r.id === group[0].request_id), // orphans re-import
},
}));
}
// Several entries on one request is either a multi-part title (merge them) or a
// mismatch (skip them). The user decides once, then keeps the rest importable.
function askAboutSharedRequests(shared) {
const dlg = $("#conflict-dialog");
$("#conflict-intro").textContent =
`${shared.length} Anfrage(n) sind mehreren Einträgen zugeordnet. Gehören die Einträge ` +
"zusammen (Mehrteiler), lassen sie sich als ein Hörbuch importieren — sonst " +
"überspringst du sie, korrigierst die Zuordnung und importierst sie später.";
$("#conflict-list").innerHTML = shared
.map((group) => {
const req = missingReqs.find((r) => r.id === group[0].request_id);
return `<label><span>${esc(req ? req.title : "#" + group[0].request_id)}</span>
<span class="muted">← ${group.map((it) => esc(it.name)).join(" + ")}</span></label>`;
})
.join("");
dlg.showModal();
return new Promise((resolve) => {
const done = (answer) => {
dlg.close();
resolve(answer);
};
$("#conflict-merge").onclick = () => done("merge");
$("#conflict-skip").onclick = () => done("skip");
$("#conflict-cancel").onclick = () => done("cancel");
});
}
async function collapseSharedRequests(chosen) {
const groups = groupByRequest(chosen);
scanItems.forEach((it) => (it.conflict = false));
skippedConflicts = 0;
const shared = groups.filter((g) => g.length > 1);
if (!shared.length) return buildImportGroups(groups);
const answer = await askAboutSharedRequests(shared);
if (answer === "cancel") return null;
if (answer === "merge") return buildImportGroups(groups);
// skip: mark the clashing entries, deselect them, import everything else
const skipped = shared.flat();
skipped.forEach((it) => {
it.conflict = true;
it.checked = false;
});
skippedConflicts = shared.length;
return buildImportGroups(groups.filter((g) => g.length === 1));
}
// deepest folder that holds all of the group's entries
function sharedParent(group) {
const parts = group.map((it) => it.path.split("/"));
const first = parts[0];
let i = 0;
while (i < first.length - 1 && parts.every((p) => p[i] === first[i])) i++;
return first.slice(0, i).join("/") || group[0].path;
}
$("#import-btn").addEventListener("click", async () => {
const chosen = scanItems.filter((item) => item.checked && item.request_id);
if (!chosen.length) { toast("Nichts ausgewählt", true); return; }
const groups = await collapseSharedRequests(chosen);
if (!groups) return;
if (!groups.length) {
$("#import-summary").innerHTML =
`<p class="error">Nichts importiert: alle ausgewählten Einträge waren mehreren ` +
`Anfragen zugeordnet — Filter „Nur Konflikte" zeigt sie.</p>`;
renderImportTable();
return;
}
const btn = $("#import-btn");
btn.disabled = true;
scanItems.forEach((it) => { it.importState = null; it.importError = ""; });
const total = groups.length;
const allResults = [];
let done = 0;
setImportProgress(0, total);
try {
// one request per entry: the row can only turn green once its own move came
// back, and a batch would only report after all of its items finished
for (const { item, parts } of groups) {
parts.forEach((p) => (p.importState = "running"));
// follow the running entry across pages, otherwise only the bar moves
applyImportView();
const pos = viewIdx.indexOf(scanItems.indexOf(parts[0]));
if (pos >= 0) importPage = Math.floor(pos / IMPORT_PAGE_SIZE);
renderImportTable();
let result;
try {
const res = await api("/api/import", {
method: "POST", body: JSON.stringify({ items: [item] }),
});
result = res.results[0];
} catch (err) {
result = { path: item.path, ok: false, error: err.message };
}
allResults.push(result);
parts.forEach((p) => {
p.importState = result.ok ? "done" : "failed";
p.importError = result.ok ? "" : result.error;
p.checked = false;
});
done += 1;
setImportProgress(done, total);
renderImportTable();
}
const failed = allResults.filter((r) => !r.ok);
$("#import-summary").innerHTML =
`<p class="${failed.length ? "error" : "ok"}"><strong>` +
`${allResults.length - failed.length} importiert` +
(failed.length ? `, ${failed.length} Fehler` : "") +
`</strong></p>` +
(skippedConflicts
? `<p class="error">${skippedConflicts} Anfrage(n) waren mehreren Einträgen ` +
`zugeordnet und wurden übersprungen — Filter „Nur Konflikte" zeigt sie.</p>`
: "");
$("#import-results").innerHTML = [...failed, ...allResults.filter((r) => r.ok)]
.map((r) =>
r.ok
? `<p class="ok">✓ ${esc(r.path)}${esc(r.dest)}</p>`
: `<p class="error">✗ ${esc(r.path)}: ${esc(r.error)}</p>`
)
.join("");
toast(failed.length ? `Import fertig, ${failed.length} Fehler` : "Import fertig", failed.length > 0);
refreshMissingBadge();
clearTimeout(cleanupTimer);
cleanupTimer = setTimeout(() => {
// only the successful ones go; failures and conflicts stay to be dealt with
const before = scanItems.length;
scanItems = scanItems.filter((it) => it.importState !== "done");
if (scanItems.length !== before) {
importPage = 0;
setImportProgress(0, 0);
renderImportTable();
$("#scan-info").textContent =
`${scanItems.length} Kandidat(en) übrig — für den aktuellen Stand erneut scannen`;
}
}, IMPORT_DONE_CLEANUP_MS);
// the list stays as it is, with its green and red rows - rescanning would
// wipe exactly the feedback the user just waited for
} finally {
btn.disabled = false;
btn.textContent = "Ausgewählte importieren";
}
});
function setImportProgress(done, total) {
const box = $("#import-progress");
if (!total) { box.hidden = true; return; }
if (done >= total) {
box.hidden = done < total;
if (done >= total) {
box.querySelector("progress").value = total;
box.querySelector("span").textContent = `${total}/${total} · fertig`;
}
return;
}
box.hidden = false;
const bar = box.querySelector("progress");
bar.max = total;
bar.value = done;
box.querySelector("span").textContent =
`${done}/${total} · ${Math.round((done / total) * 100)} %`;
}
// ---- init ----
loadLibraries();
refreshMissingBadge();