add(ui/ux): skip conflicting entries instead of blocking the whole import
This commit is contained in:
+73
-19
@@ -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
|
||||
|
||||
@@ -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 & Anfragen</h3>
|
||||
<p class="muted mono" id="quick-file"></p>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user