fix(ui/ux): let the select buttons include entries without an assignment
This commit is contained in:
+117
-11
@@ -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>'}
|
||||
|
||||
@@ -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 & verbinden</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="conflict-dialog">
|
||||
<h3>Mehrfach zugeordnete Anfragen</h3>
|
||||
<p class="muted" id="conflict-intro"></p>
|
||||
|
||||
Reference in New Issue
Block a user