fix(ui/ux): let the select buttons include entries without an assignment

This commit is contained in:
Steppenstreuner
2026-08-29 14:59:48 +02:00
parent acaf7c2068
commit 8a05d2d3c3
11 changed files with 394 additions and 18 deletions
+117 -11
View File
@@ -3,6 +3,7 @@ const $$ = (sel) => document.querySelectorAll(sel);
let libraries = [];
const LANGUAGE_NAMES = { german: "Deutsch", english: "Englisch" };
const SOURCE_NAMES = { musicbrainz: "MusicBrainz" };
async function api(path, opts = {}) {
const resp = await fetch(path, {
@@ -220,7 +221,7 @@ $("#search-form").addEventListener("submit", async (e) => {
<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>
<span class="muted">${r.year ?? ""} ${r.language ? "· " + esc(LANGUAGE_NAMES[r.language] || r.language) : ""} ${r.source && r.source !== "audible" ? "· " + esc(SOURCE_NAMES[r.source] || r.source) : ""} ${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>
@@ -890,20 +891,31 @@ $("#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) => {
selectIndices(pageIdx, "dieser Seite");
});
$("#import-select-all").addEventListener("click", () => {
applyImportView();
selectIndices(viewIdx, "insgesamt");
});
// Entries without a suggestion are selected too - they are exactly the ones
// "Anfragen aus Ordnernamen" works on.
function selectIndices(indices, what) {
scanItems.forEach((it) => (it.checked = false)); // "take what I see", not "add"
indices.forEach((i) => {
const item = scanItems[i];
const req = item.request_id || item.suggested_request_id;
if (!req) return;
item.request_id = req;
if (req) 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);
});
const withReq = indices.filter((i) => scanItems[i].request_id).length;
toast(
`${indices.length} Einträge ${what} ausgewählt` +
(withReq < indices.length ? `, davon ${indices.length - withReq} ohne Zuordnung` : "")
);
}
$("#import-deselect-all").addEventListener("click", () => {
applyImportView();
@@ -911,6 +923,96 @@ $("#import-deselect-all").addEventListener("click", () => {
renderImportTable();
});
// ---- create requests straight from folder names ----
// For series that Audible only knows in part (Lady Bedfort: 34 of 117) the
// folder names are the better source - they carry number and title already.
let namesTargets = [];
function nameToRequest(item) {
const { number, rest } = splitEpisodeNumber(item.name);
return { title: cleanFileName(rest) || item.name, volume: number };
}
$("#import-from-names").addEventListener("click", () => {
namesTargets = scanItems.filter((it) => it.checked && !it.request_id);
if (!namesTargets.length) {
toast("Keine ausgewählten Einträge ohne Zuordnung", true);
return;
}
const type = namesTargets[0].media_type;
namesTargets = namesTargets.filter((it) => it.media_type === type);
$("#names-intro").textContent =
`${namesTargets.length} Einträge ohne Zuordnung (${type}). Titel und Folgennummer ` +
"kommen aus dem Ordnernamen; Serie und Autor gelten für alle.";
$("#names-series").value = lastSeries;
$("#names-authors").value = "";
$("#names-library").innerHTML = libOptions(type) || "<option value=''>— keine Library —</option>";
renderNamesPreview();
$("#names-dialog").showModal();
});
function renderNamesPreview() {
const shown = namesTargets.slice(0, 12);
$("#names-preview").innerHTML =
shown
.map((it) => {
const { title, volume } = nameToRequest(it);
return `<label><span class="num">${volume ?? "—"}</span><span>${esc(title)}</span></label>`;
})
.join("") +
(namesTargets.length > shown.length
? `<div class="gap">… und ${namesTargets.length - shown.length} weitere</div>`
: "");
}
$("#names-series").addEventListener("input", renderNamesPreview);
$("#names-cancel").addEventListener("click", () => $("#names-dialog").close());
$("#names-submit").addEventListener("click", async () => {
const libId = $("#names-library").value;
if (!libId) { toast("Erst eine Library für diesen Typ anlegen", true); return; }
const series = (lastSeries = $("#names-series").value.trim());
const authors = $("#names-authors").value.trim();
const btn = $("#names-submit");
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Lege an…';
try {
const items = namesTargets.map((it) => ({ ...nameToRequest(it), series, authors }));
const res = await api("/api/requests/bulk-items", {
method: "POST",
body: JSON.stringify({ library_id: parseInt(libId), items }),
});
// reload so that entries skipped as duplicates can be linked to the request
// that already existed
missingReqs = await api("/api/requests?status=missing");
const key = (t, v) => `${(t || "").trim().toLowerCase()}|${v ?? ""}`;
const byKey = new Map(missingReqs.map((r) => [key(r.title, r.volume), r]));
let linked = 0;
namesTargets.forEach((it) => {
const { title, volume } = nameToRequest(it);
const req = byKey.get(key(title, volume));
if (!req) return;
it.request_id = req.id;
it.checked = true;
linked += 1;
});
setLastLib(namesTargets[0].media_type, libId);
$("#names-dialog").close();
renderImportTable();
refreshMissingBadge();
toast(
`${res.created.length} Anfragen angelegt` +
(res.skipped ? `, ${res.skipped} gab es schon` : "") +
`, ${linked} Einträge verbunden`
);
} catch (err) {
toast(err.message, true);
} finally {
btn.disabled = false;
btn.textContent = "Anlegen & verbinden";
}
});
// ---- 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) {
@@ -995,6 +1097,7 @@ function splitItem(i) {
let quickItemIndex = null;
let quickEpisode = null;
let quickPicked = null; // metadata hit the manual fields were filled from
let quickAutoSearch = false; // the search fired on open, not by the user
let lastSeries = "";
// "017 - Titel", "Folge 17: Titel", "[003] Titel" -> episode number + rest.
@@ -1050,6 +1153,7 @@ function openQuickDialog(i) {
$("#quick-results").innerHTML = "";
renderQuickOpenRequests(item);
$("#quick-dialog").showModal();
quickAutoSearch = true; // opening the dialog searches Audible only
runQuickSearch();
}
@@ -1117,8 +1221,9 @@ async function runQuickSearch() {
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 || ""}`
`&language=${lib?.language || ""}&fallback=${quickAutoSearch ? "false" : "true"}`
);
quickAutoSearch = false;
if (!results.length) {
box.innerHTML = `<p class='muted'>${EMPTY_HINTS[item.media_type]}</p>`;
return;
@@ -1132,6 +1237,7 @@ async function runQuickSearch() {
r.series ? `📚 ${r.series}${r.volume != null ? " #" + r.volume : ""}` : "",
r.year ?? "",
LANGUAGE_NAMES[r.language] || r.language,
r.source && r.source !== "audible" ? SOURCE_NAMES[r.source] || r.source : "",
].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>'}
+23
View File
@@ -165,6 +165,10 @@
<button id="import-select-suggested" class="secondary">
Alle mit Vorschlag auswählen
</button>
<button id="import-select-all" class="secondary"
title="Alle Einträge der aktuellen Ansicht auswählen, auch ohne Zuordnung">
Alle auswählen
</button>
<button id="import-select-page" class="secondary"
title="Nur die Einträge dieser Seite auswählen, alle anderen abwählen">
Diese Seite auswählen
@@ -175,6 +179,10 @@
<button id="import-merge" class="secondary" title="Mehrteiler: ausgewählte Einträge als ein Hörbuch importieren">
Ausgewählte zusammenfassen
</button>
<button id="import-from-names" class="secondary"
title="Für ausgewählte Einträge ohne Zuordnung Anfragen aus den Ordnernamen anlegen">
Anfragen aus Ordnernamen
</button>
</div>
<div class="table-scroll">
<table id="import-table" hidden>
@@ -206,6 +214,21 @@
<div id="import-summary"></div>
<div id="import-results"></div>
<dialog id="names-dialog">
<h3>Anfragen aus Ordnernamen</h3>
<p class="muted" id="names-intro"></p>
<div class="bar wrap">
<label>Serie <input id="names-series" placeholder="optional, für alle" /></label>
<label>Autor <input id="names-authors" placeholder="optional, für alle" /></label>
<label>Library <select id="names-library"></select></label>
</div>
<div id="names-preview" class="series-list"></div>
<div class="row">
<button type="button" id="names-cancel" class="secondary">Abbrechen</button>
<button type="button" id="names-submit">Anlegen &amp; verbinden</button>
</div>
</dialog>
<dialog id="conflict-dialog">
<h3>Mehrfach zugeordnete Anfragen</h3>
<p class="muted" id="conflict-intro"></p>