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:
123
src/App.tsx
123
src/App.tsx
@@ -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">
|
||||
|
||||
14
src/api.ts
14
src/api.ts
@@ -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 }),
|
||||
|
||||
104
src/styles.css
104
src/styles.css
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user