Entries now sit at places 1…max with gaps allowed (#28, nothing at #29, #30). Moving onto an empty place just puts the entry there; onto a taken place, the entries in between shift one step, stopping at the first empty place, so lists without gaps reorder exactly as before. Inserts push down only to the next gap; deleting can close up or leave the place empty. New endpoint POST /api/lists/{id}/places inserts or removes empty places or closes every gap; move and delete reply with every entry's place. The podium shows an empty step for an empty #2 or #3, and a list can't shrink below its last used place. The editor loads every entry (no paging) and applies edits instantly with the same rules (places.js mirrors places.go), queueing saves in the background and reconciling with the server's reply. Type a place and press Enter (↑/↓ move between boxes, Alt+↑/↓ moves the entry), drag, or use the row menu. Empty runs show as rows with Add here and Remove, and a toolbar shows the gap count, Close gaps and a Go to # box. Clicking an entry opens it for editing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
339 lines
16 KiB
JavaScript
339 lines
16 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 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 your last one, 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";
|
|
}
|