Overhaul card-data ingest: filter art cards, segmented cancellable progress

- Exclude Scryfall Art Series cards (layout "art_series") from ingest —
  they aren't playable cards and were polluting search results.
- Replace the coarse per-stage progress text with a byte-accurate,
  weighted multi-segment progress bar (Index / Card data / Printings /
  Indexing) that fills as one continuous bar instead of swapping per
  stage; segment weights are computed from the real download sizes
  reported by Scryfall's bulk-data index (the download responses
  themselves are chunked with no Content-Length to rely on).
- Add a Cancel button, wired through a new CancelToken checked once per
  parsed record so cancellation lands almost instantly.
- Fix a related correctness bug this surfaced: ingest used to delete the
  existing reference.db before writing its replacement, so a cancelled
  or crashed update left the app with no card database at all. It now
  builds into a temp file and only atomically replaces reference.db on
  full success.
- Split the topbar's single "Choose library…" action into separate
  "Open Library…" (existing file) and "New Library…" (save-dialog for a
  new file) buttons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 20:22:31 -05:00
parent 0eaa41ec41
commit 342b8da797
9 changed files with 623 additions and 48 deletions

View File

@@ -1,7 +1,7 @@
import { useEffect, useState, useCallback, ReactNode } from "react";
import { useEffect, useRef, useState, useCallback, ReactNode } from "react";
import { listen } from "@tauri-apps/api/event";
import { open } from "@tauri-apps/plugin-dialog";
import { api, Status } from "./api";
import { open, save } from "@tauri-apps/plugin-dialog";
import { api, IngestProgress, IngestStageInfo, Status } from "./api";
import { HoverPreviewProvider, usePersistedState } from "./components";
import { CardEntry } from "./views/CardEntry";
import { LibraryBrowser } from "./views/LibraryBrowser";
@@ -51,11 +51,24 @@ const NAV: { id: Tab; label: string; icon: ReactNode }[] = [
},
];
/** Percent-fill (0..100) of one segment, given overall cumulative percent. */
function segmentFill(stages: IngestStageInfo[], index: number, overallPercent: number): number {
let start = 0;
for (let i = 0; i < index; i++) start += stages[i].weight;
const end = start + stages[index].weight;
const [startPct, endPct] = [start * 100, end * 100];
if (overallPercent <= startPct) return 0;
if (overallPercent >= endPct) return 100;
if (endPct === startPct) return 100;
return ((overallPercent - startPct) / (endPct - startPct)) * 100;
}
export default function App() {
const [status, setStatus] = useState<Status | null>(null);
const [tab, setTab] = usePersistedState<Tab>("active-tab", "add");
const [progress, setProgress] = useState<string | null>(null);
const [progress, setProgress] = useState<IngestProgress | null>(null);
const [busy, setBusy] = useState(false);
const lastProgress = useRef<IngestProgress | null>(null);
const refreshStatus = useCallback(async () => {
setStatus(await api.getStatus());
@@ -63,7 +76,10 @@ export default function App() {
useEffect(() => {
refreshStatus();
const un = listen<string>("ingest-progress", (e) => setProgress(e.payload));
const un = listen<IngestProgress>("ingest-progress", (e) => {
lastProgress.current = e.payload;
setProgress(e.payload);
});
return () => {
un.then((f) => f());
};
@@ -71,21 +87,57 @@ export default function App() {
async function updateCardData() {
setBusy(true);
setProgress("Starting…");
try {
const s = await api.updateCardData();
setStatus(s);
setProgress(`Loaded ${s.card_count.toLocaleString()} cards.`);
setProgress({
stage: "done",
message: `Loaded ${s.card_count.toLocaleString()} cards.`,
overall_percent: 100,
stages: lastProgress.current?.stages ?? [],
});
setTimeout(() => setProgress((p) => (p?.stage === "done" ? null : p)), 4000);
} catch (err) {
setProgress(`Failed: ${String(err)}`);
const msg = String(err);
if (msg === "cancelled") {
setProgress({
stage: "cancelled",
message: "Cancelled.",
overall_percent: lastProgress.current?.overall_percent ?? 0,
stages: lastProgress.current?.stages ?? [],
});
setTimeout(() => setProgress((p) => (p?.stage === "cancelled" ? null : p)), 3000);
} else {
setProgress({
stage: "error",
message: `Failed: ${msg}`,
overall_percent: lastProgress.current?.overall_percent ?? 0,
stages: lastProgress.current?.stages ?? [],
});
}
} finally {
setBusy(false);
}
}
async function chooseLibrary() {
async function cancelUpdate() {
await api.cancelUpdateCardData();
}
async function openLibrary() {
const path = await open({
title: "Choose or create library.db (put it in a synced folder)",
title: "Open an existing library.db",
filters: [{ name: "SQLite", extensions: ["db", "sqlite"] }],
});
if (typeof path === "string") {
setStatus(await api.setLibraryPath(path));
}
}
async function newLibrary() {
const path = await save({
title: "Create a new library.db (put it in a synced folder)",
defaultPath: "library.db",
filters: [{ name: "SQLite", extensions: ["db", "sqlite"] }],
});
if (typeof path === "string") {
@@ -131,15 +183,60 @@ export default function App() {
</span>
)}
<span className="spacer" />
{progress && <span className="progress">{progress}</span>}
<button onClick={chooseLibrary} disabled={busy}>
Choose library…
<button onClick={openLibrary} disabled={busy}>
Open Library…
</button>
<button onClick={newLibrary} disabled={busy}>
New Library…
</button>
<button onClick={updateCardData} disabled={busy} className="primary">
{ready ? "Update card data" : "Download card data"}
</button>
</header>
{progress && (
<div className={`ingest-bar ingest-bar-${progress.stage}`}>
<div className="ingest-bar-head">
<span className="ingest-bar-message">{progress.message}</span>
<span className="spacer" />
{busy && (
<button className="ingest-bar-cancel" onClick={cancelUpdate}>
Cancel
</button>
)}
{!busy &&
(progress.stage === "done" ||
progress.stage === "error" ||
progress.stage === "cancelled") && (
<button
className="ingest-bar-dismiss"
onClick={() => setProgress(null)}
title="Dismiss"
>
✕
</button>
)}
</div>
<div className="ingest-segments">
{progress.stages.map((s, i) => (
<div
key={s.id}
className={`ingest-segment ${s.id === progress.stage ? "active" : ""}`}
style={{ flexGrow: s.weight, flexBasis: 0 }}
>
<div className="ingest-segment-track">
<div
className="ingest-segment-fill"
style={{ width: `${segmentFill(progress.stages, i, progress.overall_percent)}%` }}
/>
</div>
<span className="ingest-segment-label">{s.label}</span>
</div>
))}
</div>
</div>
)}
<div className="content">
{!ready ? (
<div className="empty-state">