// Import a list from a CSV or text file: read it, search for each entry, // add the certain matches as-is and ask about the rest. import { h, icon, api, toast, openDialog, dialogHead, busy, fmt } from "./common.js"; import { previewButton } from "./player.js"; const CONCURRENCY = 3; export function openImport({ list, cat, onListChange, onDone }) { let dlg; const file = h("input", { type: "file", accept: ".csv,.tsv,.txt,text/csv,text/plain,text/tab-separated-values", class: "import-file" }); const text = h("textarea", { rows: 6, placeholder: "Or paste titles here, one per line", "aria-label": "Titles to import" }); const error = h("p", { class: "alert alert--error", role: "alert", hidden: true }); const next = h("button", { type: "submit", class: "btn btn--primary" }, "Find matches"); const fileLabel = h("span", { class: "import-drop-text" }, h("b", {}, "Choose a .csv or .txt file"), h("span", { class: "muted small" }, "or drop it here")); const drop = h("label", { class: "import-drop" }, icon("upload"), fileLabel, file); file.addEventListener("change", () => { const f = file.files[0]; fileLabel.replaceChildren(h("b", {}, f ? f.name : "Choose a .csv or .txt file"), h("span", { class: "muted small" }, f ? `${Math.ceil(f.size / 1024)} KB · click to change` : "or drop it here")); }); for (const ev of ["dragenter", "dragover"]) drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add("is-over"); }); for (const ev of ["dragleave", "drop"]) drop.addEventListener(ev, () => drop.classList.remove("is-over")); drop.addEventListener("drop", (e) => { e.preventDefault(); if (e.dataTransfer.files.length) { file.files = e.dataTransfer.files; file.dispatchEvent(new Event("change")); } }); const form = h("form", {}, dialogHead("Import a list", () => dlg), h("div", { class: "dialog-body stack" }, h("p", { class: "muted" }, `Entries are added after #${fmt.format(list.itemCount)} in the file's order. Use one title per line, or a CSV with columns like Title, Year, ${artistWord(cat)} and Note. `, "Each one is looked up and matched for you; anything Canon isn't sure about is left for you to check."), drop, text, error, ), h("div", { class: "dialog-foot" }, h("button", { type: "button", class: "btn", onclick: () => dlg.close() }, "Cancel"), next, ), ); form.addEventListener("submit", async (e) => { e.preventDefault(); error.hidden = true; let body; if (file.files[0]) { body = new FormData(); body.append("file", file.files[0]); } else if (text.value.trim()) { body = { text: text.value }; } else { error.hidden = false; error.textContent = "Choose a file or paste some titles first."; return; } busy(next, true); try { const res = await api("POST", "/api/import/parse", body); dlg.close(); review(res.rows, res.rows.length >= res.max ? res.max : 0); } catch (err) { error.hidden = false; error.textContent = err.message; } finally { busy(next, false); } }); dlg = openDialog(form); dlg.style.maxWidth = "560px"; // ---------- step 2: match and review ---------- function review(rows, capped) { let closed = false; let matching = true; let onlyCheck = false; const entries = rows.map((row, i) => ({ row, i, state: "pending", results: [], choice: null, open: false })); const bar = h("span", { style: { width: "0%" } }); const meter = h("div", { class: "meter", style: { margin: 0 } }, bar); const summary = h("p", { class: "import-summary", "aria-live": "polite" }); const filter = h("label", { class: "check small" }, h("input", { type: "checkbox", onchange: (e) => { onlyCheck = e.target.checked; renderAll(); }, }), "Show only ones to check"); const listEl = h("ol", { class: "import-list" }); const room = h("p", { class: "alert alert--warn", hidden: true }); const go = h("button", { type: "button", class: "btn btn--primary", disabled: true }, "Import"); const body = h("div", { class: "dialog-body import-body" }, capped ? h("p", { class: "alert alert--warn" }, icon("info"), `Only the first ${fmt.format(capped)} entries are imported at a time.`) : null, h("div", { class: "import-top" }, summary, filter), meter, listEl); const foot = h("div", { class: "dialog-foot" }, room, h("span", { class: "spacer" }), h("button", { type: "button", class: "btn", onclick: () => rdlg.close() }, "Cancel"), go); const rdlg = openDialog(h("div", { class: "import-dialog-inner" }, dialogHead("Check the matches", () => rdlg), body, foot), { className: "import-dialog", onClose: () => { closed = true; }, }); const chosen = () => entries.filter((e) => e.choice !== null && e.choice !== "skip"); const needsCheck = (e) => e.state === "review" || e.state === "none" || e.state === "error"; function renderTop() { const done = entries.filter((e) => e.state !== "pending").length; bar.style.width = `${Math.round((done / entries.length) * 100)}%`; meter.hidden = !matching; const count = (s) => entries.filter((e) => e.state === s).length; const parts = matching ? [`Matching ${fmt.format(done)} of ${fmt.format(entries.length)}…`] : [ `${fmt.format(count("matched"))} matched`, count("review") && `${fmt.format(count("review"))} to check`, count("none") + count("error") && `${fmt.format(count("none") + count("error"))} not found`, count("dupe") && `${fmt.format(count("dupe"))} already on the list`, ].filter(Boolean); summary.textContent = parts.join(" · "); const n = chosen().length; const free = list.maxItems - list.itemCount; go.disabled = matching || n === 0 || n > free; go.textContent = matching ? "Matching…" : `Import ${fmt.format(n)} ${n === 1 ? "entry" : "entries"}`; room.hidden = matching || n <= free; if (!room.hidden) { const want = Math.min(list.itemCount + n, 65535); room.replaceChildren(icon("info"), `This list has room for ${fmt.format(free)} more. `, h("button", { type: "button", class: "btn btn--sm", onclick: async (e) => { busy(e.currentTarget, true); try { Object.assign(list, await api("PUT", `/api/lists/${list.id}`, { ...list, maxItems: want })); onListChange?.(list); renderTop(); } catch (err) { toast(err.message, { error: true }); busy(e.currentTarget, false); } }, }, `Raise the size to ${fmt.format(want)}`)); } } function renderAll() { listEl.replaceChildren(...entries.filter((e) => !onlyCheck || needsCheck(e)).map(renderEntry)); renderTop(); } function rerender(e) { const old = listEl.querySelector(`[data-i="${e.i}"]`); if (old) old.replaceWith(renderEntry(e)); renderTop(); } function renderEntry(e) { const { row } = e; const asWritten = [row.subtitle, row.year].filter(Boolean).join(" · "); const badge = { pending: h("span", { class: "badge badge--muted" }, "Matching…"), matched: h("span", { class: "badge badge--ok" }, icon("check"), "Matched"), review: h("span", { class: "badge badge--warn" }, "Check this"), none: h("span", { class: "badge badge--warn" }, "Not found"), error: h("span", { class: "badge badge--warn", title: e.error }, "Search failed"), dupe: h("span", { class: "badge badge--muted" }, `Already #${e.results[0]?.existingRank}`), }[e.state]; const li = h("li", { class: `import-row is-${e.state}`, "data-i": e.i }, h("div", { class: "import-row-head" }, h("span", { class: "import-num" }, `${e.i + 1}.`), h("div", { class: "import-src" }, h("b", {}, row.title), asWritten && h("span", { class: "muted small" }, ` ${asWritten}`)), badge), ); if (e.state === "pending") return li; // Certain matches collapse to one line until the owner wants to change them. if (!e.open && (e.state === "matched" || e.state === "dupe")) { const pick = typeof e.choice === "number" ? e.results[e.choice] : null; li.append(h("div", { class: "import-chosen" }, pick ? resultCard(pick) : h("span", { class: "muted small" }, e.choice === "skip" ? "Skipped" : `Adding “${row.title}” as written`), h("button", { type: "button", class: "btn btn--sm btn--ghost", onclick: () => { e.open = true; rerender(e); } }, "Change"))); return li; } const name = `imp-${e.i}`; const option = (value, content, extraClass = "") => h("label", { class: "import-opt " + extraClass }, h("input", { type: "radio", name, checked: e.choice === value, onchange: () => { e.choice = value; renderTop(); }, }), content); const opts = e.results.map((r, idx) => option(idx, resultCard(r, idx === 0 && e.state !== "none" ? "Recommended" : ""))); opts.push(option("manual", h("span", { class: "import-new" }, icon("plus"), h("span", {}, "Add ", h("b", {}, `“${row.title}”`), " as written")), "is-new")); opts.push(option("skip", h("span", { class: "muted" }, "Skip it"), "is-skip")); const q = h("input", { type: "search", value: row.title, "aria-label": `Search again for ${row.title}` }); const again = h("form", { class: "import-again" }, icon("search"), q, h("button", { type: "submit", class: "btn btn--sm" }, "Search again")); again.addEventListener("submit", async (ev) => { ev.preventDefault(); const btn = again.querySelector("button"); busy(btn, true); // The new spelling is only for searching; "as written" keeps the original. await match(e, { ...row, title: q.value.trim() || row.title }); e.open = true; rerender(e); }); // Native append() would print a null as the text "null". if (e.error) li.append(h("p", { class: "small muted" }, e.error)); li.append(h("div", { class: "import-opts" }, ...opts), again); return li; } function resultCard(r, flag) { const sub = [r.subtitle, r.year, r.extra?.type].filter(Boolean).join(" · "); const art = h("span", { class: "result-art" + (cat.aspect === "square" ? " is-square" : "") }); if (r.imageUrl) { const img = h("img", { src: r.imageUrl, alt: "", loading: "lazy", referrerpolicy: "no-referrer" }); img.addEventListener("error", () => img.replaceWith(icon(cat.icon)), { once: true }); art.append(img); } else { art.append(icon(cat.icon)); } if (r.previewRef) art.append(previewButton(`/admin/preview?ref=${encodeURIComponent(r.previewRef)}`, `Play a preview of ${r.title}`, "play-btn play-btn--mini")); return h("span", { class: "import-card" }, art, h("span", { class: "import-card-text" }, h("span", { class: "result-title" }, r.title, flag && h("span", { class: "badge" }, flag)), sub && h("span", { class: "result-sub" }, sub), r.existingRank && h("span", { class: "small muted" }, `Already on this list at #${r.existingRank}`))); } async function match(e, row = e.row) { try { const res = await api("POST", `/api/lists/${list.id}/import/match`, { title: row.title, year: row.year || "", subtitle: row.subtitle || "" }); e.results = res.results; e.error = ""; e.state = res.status; e.choice = res.status === "none" ? "manual" : 0; if (res.results[0]?.existingRank && res.status === "matched") { e.state = "dupe"; e.choice = "skip"; } } catch (err) { e.state = "error"; e.error = err.message; e.results = []; e.choice = "manual"; if (err.status === 424) throw err; // no API key: every row would fail } } async function matchAll() { let next = 0; let fatal = null; const worker = async () => { while (!closed && !fatal && next < entries.length) { const e = entries[next++]; try { await match(e); } catch (err) { fatal = err; } rerender(e); } }; await Promise.all(Array.from({ length: CONCURRENCY }, worker)); matching = false; if (fatal) { for (const e of entries) if (e.state === "pending") { e.state = "error"; e.error = fatal.message; e.choice = "manual"; } body.prepend(h("p", { class: "alert alert--warn" }, icon("info"), fatal.message, " ", h("a", { href: "/admin/settings#integrations" }, "Open integrations →"))); } renderAll(); } go.addEventListener("click", () => runImport(entries.filter((e) => e.choice !== null && e.choice !== "skip"), rdlg)); renderAll(); matchAll(); } // ---------- step 3: add them ---------- async function runImport(todo, reviewDlg) { reviewDlg.close(); let stopped = false; const bar = h("span", { style: { width: "0%" } }); const status = h("p", { class: "muted small", "aria-live": "polite" }, "Starting…"); const body = h("div", { class: "dialog-body stack" }, h("div", { class: "meter", style: { margin: 0 } }, bar), status); const stop = h("button", { type: "button", class: "btn", onclick: () => { stopped = true; stop.disabled = true; stop.textContent = "Stopping…"; } }, "Stop"); const foot = h("div", { class: "dialog-foot" }, stop); const pdlg = openDialog(h("div", {}, h("div", { class: "dialog-head" }, h("h2", {}, "Importing…")), body, foot), { onClose: () => { stopped = true; onDone?.(); }, }); pdlg.style.maxWidth = "520px"; let added = 0, dupes = 0, done = 0; const failed = []; for (const e of todo) { if (stopped) break; status.textContent = `${fmt.format(done + 1)} of ${fmt.format(todo.length)} · ${e.row.title}`; const r = typeof e.choice === "number" ? e.results[e.choice] : null; const payload = r ? { title: r.title, subtitle: r.subtitle, year: r.year, externalUrl: r.externalUrl, imageUrl: r.imageUrl, provider: r.provider, providerId: r.providerId, extra: { ...(r.extra || {}) }, description: r.overview || "", details: true, note: e.row.note || "", } : { title: e.row.title, subtitle: e.row.subtitle || "", year: e.row.year || "", note: e.row.note || "", extra: {} }; try { const res = await api("POST", `/api/lists/${list.id}/items`, payload); added++; list.itemCount++; if (res.warning) failed.push({ title: e.row.title, error: res.warning }); } catch (err) { if (err.status === 409) dupes++; else failed.push({ title: e.row.title, error: err.message }); if (err.status === 400 && /full/i.test(err.message)) break; } done++; bar.style.width = `${Math.round((done / todo.length) * 100)}%`; } onListChange?.(list); pdlg.querySelector(".dialog-head h2").textContent = done < todo.length ? "Import stopped" : "Import finished"; body.replaceChildren(...[ h("p", {}, [ `${fmt.format(added)} added`, dupes && `${fmt.format(dupes)} skipped (already on the list)`, failed.length && `${fmt.format(failed.length)} with problems`, done < todo.length && `${fmt.format(todo.length - done)} not reached`, ].filter(Boolean).join(" · ")), failed.length > 0 && h("ul", { class: "refresh-failures" }, failed.slice(0, 12).map((f) => h("li", {}, h("b", {}, f.title), " — ", f.error)), failed.length > 12 && h("li", { class: "muted" }, `…and ${failed.length - 12} more`)), ].filter(Boolean)); foot.replaceChildren(h("button", { type: "button", class: "btn btn--primary", onclick: () => pdlg.close() }, "Done")); if (added) toast(`Imported ${fmt.format(added)} ${added === 1 ? "entry" : "entries"}`); } } function artistWord(cat) { return { movie: "Director", tv: "Network", album: "Artist", song: "Artist", game: "Developer" }[cat.key] || "Subtitle"; }