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:
garrettmills
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">

View File

@@ -54,6 +54,19 @@ export interface Status {
card_count: number;
}
export interface IngestStageInfo {
id: string;
label: string;
weight: number; // fraction of total work, 0..1, sums to 1 across all stages
}
export interface IngestProgress {
stage: "index" | "oracle" | "printings" | "optimize" | string;
message: string;
overall_percent: number; // 0..100 cumulative across all stages
stages: IngestStageInfo[];
}
// --- WUBRG bitmask helpers ------------------------------------------------
export const COLORS: { bit: number; code: string; label: string }[] = [
@@ -76,6 +89,7 @@ export const api = {
getStatus: () => invoke<Status>("get_status"),
setLibraryPath: (path: string) => invoke<Status>("set_library_path", { path }),
updateCardData: () => invoke<Status>("update_card_data"),
cancelUpdateCardData: () => invoke<void>("cancel_update_card_data"),
searchCards: (query: string, limit = 20) =>
invoke<CardRef[]>("search_cards", { query, limit }),

View File

@@ -292,14 +292,108 @@ select:focus {
text-overflow: ellipsis;
white-space: nowrap;
}
.progress {
color: var(--accent-hover);
font-size: 12px;
font-weight: 500;
}
.spacer {
flex: 1;
}
/* Ingest (card-data download/index) progress: one multi-segment bar spanning
all stages, below the topbar. */
.ingest-bar {
padding: 8px 18px 10px;
background: var(--bg-elev);
border-bottom: 1px solid var(--border-soft);
font-size: 12px;
}
.ingest-bar-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
}
.ingest-bar-message {
color: var(--muted);
font-family: ui-monospace, "SF Mono", Menlo, monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ingest-bar-cancel {
flex-shrink: 0;
padding: 3px 10px;
font-size: 11px;
color: var(--muted);
}
.ingest-bar-cancel:hover {
color: var(--danger);
border-color: var(--danger);
}
.ingest-bar-dismiss {
flex-shrink: 0;
background: none;
border: none;
color: var(--faint);
cursor: pointer;
padding: 2px 6px;
font-size: 13px;
}
.ingest-bar-dismiss:hover {
color: var(--text);
}
.ingest-segments {
display: flex;
gap: 4px;
}
.ingest-segment {
min-width: 0;
}
.ingest-segment-track {
height: 6px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.ingest-segment-fill {
height: 100%;
border-radius: 999px;
background: var(--border);
transition: width 0.2s ease;
}
.ingest-segment.active .ingest-segment-fill {
background: linear-gradient(90deg, var(--accent-hover), var(--accent));
}
.ingest-segment-label {
display: block;
margin-top: 3px;
font-size: 10px;
color: var(--faint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ingest-segment.active .ingest-segment-label {
color: var(--accent-hover);
font-weight: 600;
}
.ingest-bar-done .ingest-segment-fill {
background: var(--success);
}
.ingest-bar-done .ingest-bar-message {
color: var(--success);
}
.ingest-bar-error .ingest-segment-fill {
background: var(--danger);
}
.ingest-bar-error .ingest-bar-message {
color: var(--danger);
}
.ingest-bar-cancelled .ingest-segment-fill {
background: var(--warn);
}
.ingest-bar-cancelled .ingest-bar-message {
color: var(--warn);
}
.content {
flex: 1;
overflow: auto;