add(ui/ux): skip conflicting entries instead of blocking the whole import

This commit is contained in:
Steppenstreuner
2026-08-29 08:18:40 +02:00
parent 4f3160d5e7
commit af5fd5deba
4 changed files with 93 additions and 20 deletions
+73 -19
View File
@@ -649,6 +649,7 @@ $("#detail-form").addEventListener("submit", async (e) => {
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;
@@ -698,6 +699,7 @@ function applyImportView() {
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);
@@ -745,7 +747,7 @@ function renderImportTable() {
.filter((r) => r.media_type === item.media_type && r.imported_path)
.map((r) => option(r, " ↩︎"))
.join("");
return `<tr class="${item.importState ? "row-" + item.importState : ""}">
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 ? " 📁" : ""}
@@ -765,7 +767,9 @@ function renderImportTable() {
? `<button class="secondary" data-split="${i}" title="${item.parts ? "Zusammenfassung wieder auflösen" : "Ordner in einzelne Titel aufteilen"}">✂️</button>`
: ""}
</td>
${item.importState ? importStateCell(item) : scoreCell(item)}
${item.importState ? importStateCell(item)
: item.conflict ? '<td class="state conflict" title="Mehrere Einträge zeigen auf dieselbe Anfrage">⚠ Konflikt</td>'
: scoreCell(item)}
</tr>`;
})
.join("");
@@ -1140,26 +1144,18 @@ const IMPORT_BATCH_SIZE = 20;
// 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 collapseSharedRequests(chosen, opts = {}) {
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]);
}
const shared = [...byRequest.values()].filter((g) => g.length > 1);
if (shared.length) {
const names = shared
.map((g) => `${g.map((it) => it.name).join(" + ")}`)
.join("\n");
const ok = confirm(
`${shared.length} Anfrage(n) sind mehreren Einträgen zugeordnet:\n\n${names}\n\n` +
"Als je ein Hörbuch mit durchlaufenden Parts importieren?\n" +
"(Abbrechen: nichts wird importiert)"
);
if (!ok) return null;
}
const built = [...byRequest.values()].map((group) => ({
return [...byRequest.values()];
}
function buildImportGroups(groups) {
return groups.map((group) => ({
parts: group,
item: {
path: group.length > 1 ? sharedParent(group) : group[0].path,
@@ -1169,7 +1165,54 @@ function collapseSharedRequests(chosen, opts = {}) {
append: importedReqs.some((r) => r.id === group[0].request_id), // orphans re-import
},
}));
return opts.keepGroups ? built : built.map((b) => b.item);
}
// 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
@@ -1184,8 +1227,15 @@ function sharedParent(group) {
$("#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 = collapseSharedRequests(chosen, { keepGroups: true });
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;
@@ -1230,7 +1280,11 @@ $("#import-btn").addEventListener("click", async () => {
`<p class="${failed.length ? "error" : "ok"}"><strong>` +
`${allResults.length - failed.length} importiert` +
(failed.length ? `, ${failed.length} Fehler` : "") +
`</strong></p>`;
`</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
+16
View File
@@ -155,6 +155,7 @@
<option value="all">Alle</option>
<option value="suggested">Nur mit Vorschlag</option>
<option value="unassigned">Nur ohne Zuordnung</option>
<option value="conflict">Nur Konflikte</option>
</select>
<select id="import-sort">
<option value="none">Reihenfolge: Scan</option>
@@ -200,6 +201,21 @@
<div id="import-summary"></div>
<div id="import-results"></div>
<dialog id="conflict-dialog">
<h3>Mehrfach zugeordnete Anfragen</h3>
<p class="muted" id="conflict-intro"></p>
<div id="conflict-list" class="series-list"></div>
<div class="row">
<button type="button" id="conflict-cancel" class="secondary">Abbrechen</button>
<button type="button" id="conflict-merge" class="secondary">
Als je ein Hörbuch zusammenfassen
</button>
<button type="button" id="conflict-skip">
Konflikte überspringen, Rest importieren
</button>
</div>
</dialog>
<dialog id="quick-dialog">
<h3>Suchen &amp; Anfragen</h3>
<p class="muted mono" id="quick-file"></p>
+3
View File
@@ -62,6 +62,9 @@ button:disabled { opacity: 0.6; cursor: default; }
tr.row-running { background: rgba(90, 140, 220, 0.12); }
tr.row-done { background: rgba(80, 170, 110, 0.12); }
tr.row-failed { background: rgba(200, 90, 90, 0.14); }
tr.row-conflict { background: rgba(200, 90, 90, 0.14); }
td.state.conflict { color: #e08585; }
#conflict-dialog .series-list label { flex-direction: column; align-items: flex-start; gap: 0.1rem; }
td.state { white-space: nowrap; font-size: 0.85rem; }
td.state.done { color: var(--ok); }
td.state.failed { color: #e08585; }