IGDB allows 4 requests a second, and an import searching three entries at once (plus detail lookups) went over it. Every IGDB request now waits at least 300 ms after the last one, and a 429 is retried after 1, 2 and 4 seconds before giving up. The import dialog, ranking editor and their error messages are reworded in plainer language, and talk about "spots" instead of "places". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
346 lines
17 KiB
JavaScript
346 lines
17 KiB
JavaScript
// 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 your list here", "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 .txt or .csv file"), h("span", { class: "muted small" }, "or drag 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 .txt or .csv file"), h("span", { class: "muted small" }, f ? `${Math.ceil(f.size / 1024)} KB · click to pick another` : "or drag 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", {},
|
|
"Put one title on each line. The line number is the spot on your list, so line 1 is #1 and line 38 is #38. Leave a line blank to keep that spot open.",
|
|
list.itemCount ? " Anything already in those spots gets bumped down." : ""),
|
|
h("p", { class: "muted small" },
|
|
`Using a spreadsheet? Columns called Title, Year, ${artistWord(cat)} and Note are picked up automatically. `,
|
|
"Canon looks everything up for you and only asks about the ones it isn't sure of."),
|
|
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(); },
|
|
}), "Only show the 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 picked = chosen();
|
|
const n = picked.length;
|
|
const lastPlace = Math.max(0, ...picked.map((e) => e.row.place));
|
|
const fits = list.itemCount + n <= list.maxItems && lastPlace <= list.maxItems;
|
|
go.disabled = matching || n === 0 || !fits;
|
|
go.textContent = matching ? "Matching…" : `Import ${fmt.format(n)} ${n === 1 ? "entry" : "entries"}`;
|
|
room.hidden = matching || n === 0 || fits;
|
|
if (!room.hidden) {
|
|
const want = Math.min(Math.max(list.itemCount + n, lastPlace), 65535);
|
|
room.replaceChildren(icon("info"), lastPlace > list.maxItems
|
|
? `Your file goes up to #${fmt.format(lastPlace)}, but this list stops at #${fmt.format(list.maxItems)}. `
|
|
: `There's only room for ${fmt.format(list.maxItems - list.itemCount)} 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); }
|
|
},
|
|
}, `Make it a Top ${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" }, `#${row.place}`),
|
|
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"), "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 || "", rank: e.row.place,
|
|
}
|
|
: { title: e.row.title, subtitle: e.row.subtitle || "", year: e.row.year || "", note: e.row.note || "", extra: {}, rank: e.row.place };
|
|
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";
|
|
}
|