Import lists from CSV or text and match each entry

The list editor gets an Import button. A .csv/.tsv/.txt file or pasted
text is parsed on the server: header columns (Title, Year, Artist or
Director, Note, Rank), numbered lines and trailing "(1999)" years are
understood, and Canon's own export round-trips.

Each row is then searched with the list's provider and scored. A row is
matched automatically only when one result fits exactly and nothing else
fits as well; a year or artist in the file breaks ties, an exact title
beats a bracketed version like "(Live)", and "Artist - Title" lines are
tried both ways. Custom lists need a year or subtitle to be certain.
Everything else is shown for review with a recommended pick, the other
results, "Add as written", Skip, and a search-again box. Entries are
added in file order after the existing ones, with any notes from the
file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-10-08 13:54:02 -07:00
1 parent aadacaff95
commit dadf835be3
10 files changed
+1131

No files matched your search

+41
View File
@@ -827,3 +827,44 @@ input[type="checkbox"] { accent-color: var(--accent); width: 18px; height: 18px;
.backup-list .backup-when { flex: 1; min-width: 0; font-size: 14px; font-weight: 550; }
.backup-list .backup-meta { display: block; color: var(--muted); font-size: 12.5px; font-weight: 400; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.backup-list .backup-actions { display: flex; gap: 2px; flex: none; }
/* ---------- import ---------- */
.import-drop {
display: flex; align-items: center; gap: 14px; padding: 18px; cursor: pointer;
border: 1.5px dashed var(--border); border-radius: var(--radius); background: var(--surface);
transition: border-color 0.15s, background 0.15s;
}
.import-drop:hover, .import-drop.is-over { border-color: var(--accent); background: var(--accent-soft); }
.import-drop > .icon { width: 24px; height: 24px; color: var(--accent); flex: none; }
.import-drop-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.import-file { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.import-dialog { max-width: min(760px, calc(100vw - 32px)); width: 100%; }
.import-dialog-inner { display: flex; flex-direction: column; max-height: calc(100dvh - 48px); }
.import-body { display: flex; flex-direction: column; gap: 12px; overflow-y: auto; min-height: 0; }
.import-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.import-summary { font-weight: 600; font-size: 14px; }
.import-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.import-row { border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; display: flex; flex-direction: column; gap: 10px; background: var(--surface); }
.import-row.is-review, .import-row.is-none, .import-row.is-error { border-color: color-mix(in srgb, var(--warn) 45%, var(--border)); }
.import-row-head { display: flex; align-items: center; gap: 10px; min-width: 0; }
.import-num { color: var(--faint); font-size: 13px; font-variant-numeric: tabular-nums; min-width: 2.5ch; text-align: right; }
.import-src { flex: 1; min-width: 0; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.import-src b { font-weight: 600; }
.badge--ok { background: color-mix(in srgb, var(--success) 16%, transparent); color: var(--success); }
.badge .icon { width: 12px; height: 12px; }
.import-chosen { display: flex; align-items: center; gap: 10px; justify-content: space-between; padding-left: calc(2.5ch + 10px); }
.import-card { display: flex; align-items: center; gap: 10px; min-width: 0; }
.import-card .result-art { width: 38px; flex: none; }
.import-card-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.import-card .result-title { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.import-opts { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 6px; }
.import-opt { display: flex; align-items: center; gap: 10px; padding: 6px 8px; border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; min-width: 0; }
.import-opt:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
.import-opt input { accent-color: var(--accent); margin: 0; flex: none; }
.import-opt.is-new, .import-opt.is-skip { font-size: 13.5px; }
.import-new { display: flex; align-items: center; gap: 8px; min-width: 0; }
.import-new .icon { width: 16px; height: 16px; color: var(--accent); flex: none; }
.import-again { display: flex; align-items: center; gap: 8px; }
.import-again > .icon { width: 16px; height: 16px; color: var(--faint); flex: none; }
.import-row .import-again input[type="search"] { flex: 1; min-width: 0; height: 30px; font-size: 13px; }
.dialog-foot > .alert { margin: 0; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; }
+7
View File
@@ -3,6 +3,7 @@ import { h, icon, api, readBoot, toast, confirmDialog, chooseDialog, openDialog,
import { sortable } from "./sortable.js";
import { openListForm } from "./list-form.js";
import { previewButton } from "./player.js";
import { openImport } from "./import.js";
const boot = readBoot();
const { categories, limits, productions } = boot;
@@ -261,6 +262,12 @@ async function runRefreshAll(targets, replaceCustomArt) {
document.querySelector("[data-action=refresh-all]")?.addEventListener("click", refreshAll);
document.querySelector("[data-action=import]").addEventListener("click", () => openImport({
list, cat,
onListChange: (l) => { list = l; renderHeader(); },
onDone: () => load(pages()),
}));
// Songs and albums from Deezer, or matched to Deezer when saved, can play.
function canPreview(it) {
return (cat.key === "song" || cat.key === "album") && (it.provider === "deezer" || !!it.extra?.deezer);
+337
View File
@@ -0,0 +1,337 @@
// 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);
});
li.append(e.error ? h("p", { class: "small muted" }, e.error) : null,
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";
}
+1
View File
@@ -13,6 +13,7 @@
</div>
<div class="admin-head-actions">
<a class="btn btn--ghost" data-bind="public" href="/l/{{.Data.List.Slug}}">{{icon "external"}}View</a>
<button class="btn btn--ghost" type="button" data-action="import" title="Add entries from a CSV or text file">{{icon "upload"}}Import</button>
{{template "export" .Data.List.Slug}}
{{if ne $c.Key "custom"}}<button class="btn btn--ghost" type="button" data-action="refresh-all" title="Pull the latest details and artwork from each entry's source">{{icon "refresh"}}Refresh all</button>{{end}}
<button class="btn" type="button" data-action="edit-list">{{icon "settings"}}List settings</button>