ManaBox import, with progress and a format explainer

Library → Import cards… now reads a ManaBox collection export, either the
.csv or the .txt. The CSV is matched by Scryfall ID, falling back to name,
set and collector number, and its Foil column picks the copy group's finish.
Condition, language and prices are ignored; proxies count as owned.

A decklist line that names a printing now resolves to that printing's card
when several cards share the name (a card and its token, or an art-series
entry), and a bare name prefers the real card.

Imports run in one transaction (nestable via SAVEPOINT), which took the
942-row sample from 22s to 0.2s, and off the main thread, with a progress
dialog for slow ones. An explainer of the supported formats now comes
before the file picker.
This commit is contained in:
2026-10-16 21:57:52 -05:00
parent 81b1df8c9f
commit 507df81f5f
10 changed files with 715 additions and 37 deletions

View File

@@ -1,3 +1,4 @@
import { listen } from "@tauri-apps/api/event";
import { open, save } from "@tauri-apps/plugin-dialog";
import { useEffect, useRef, useState } from "react";
import {
@@ -12,12 +13,14 @@ import {
import {
CardThumb,
CardTile,
DialogFrame,
ColorPips,
ColorToggles,
GroupBySelect,
GroupMode,
InfoDialog,
LocationField,
ProgressDialog,
PromptDialog,
QtyStepper,
SortBySelect,
@@ -95,6 +98,14 @@ export function LibraryBrowser({
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
const [bulkBusy, setBulkBusy] = useState(false);
const [importResult, setImportResult] = useState<LibraryImportResult | null>(null);
const [importError, setImportError] = useState<string | null>(null);
// The "what can I import?" explainer shown before the file picker.
const [importIntroOpen, setImportIntroOpen] = useState(false);
// Lines done / total while an import runs; null when idle.
const [importProgress, setImportProgress] = useState<{ done: number; total: number } | null>(null);
// Only show the progress dialog for an import that's taking a while, so a
// quick one doesn't flash it up.
const [showImportProgress, setShowImportProgress] = useState(false);
const selectAllRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -306,14 +317,28 @@ export function LibraryBrowser({
}
async function pickImportFile() {
setImportIntroOpen(false);
const path = await open({
title: "Import library cards",
filters: [{ name: "Decklist", extensions: ["txt"] }],
filters: [{ name: "Card list (decklist or ManaBox export)", extensions: ["txt", "csv"] }],
});
if (typeof path !== "string") return;
const result = await api.importLibraryCards(path);
setImportResult(result);
setRefreshKey((k) => k + 1);
setImportProgress({ done: 0, total: 0 });
const reveal = setTimeout(() => setShowImportProgress(true), 300);
const unlisten = await listen<{ done: number; total: number }>("library-import-progress", (e) =>
setImportProgress(e.payload),
);
try {
setImportResult(await api.importLibraryCards(path));
} catch (err) {
setImportError(String(err));
} finally {
unlisten();
clearTimeout(reveal);
setShowImportProgress(false);
setImportProgress(null);
setRefreshKey((k) => k + 1);
}
}
const deckOrLocation = (c: LibraryCard) =>
@@ -455,7 +480,12 @@ export function LibraryBrowser({
Library <span className="badge">{rows.length}</span>
</h2>
<div className="page-header-actions">
<button onClick={pickImportFile}>Import cards…</button>
<button
onClick={() => setImportIntroOpen(true)}
disabled={importProgress !== null}
>
Import cards…
</button>
</div>
</div>
@@ -554,6 +584,52 @@ export function LibraryBrowser({
/>
)}
{importIntroOpen && (
<DialogFrame title="Import cards" onCancel={() => setImportIntroOpen(false)} wide>
<p className="dialog-message">
Adds the cards in a file to your library. Nothing you already own is replaced or
removed. You can import:
</p>
<ul className="dialog-list">
<li>
<strong>A card list</strong> (.txt), one card per line, like <code>4 Lightning Bolt</code>.
A line can name a printing, like <code>1 Sol Ring (2X2) 315 *F*</code>, where{" "}
<code>*F*</code> is foil and <code>*E*</code> etched. Library export, Moxfield and
Arena all write this format.
</li>
<li>
<strong>A ManaBox export</strong> of a collection or binder, either the .csv or the
.txt. The CSV records each copy's exact printing and finish, so it's the better
choice. ManaBox's condition, language and price fields aren't kept.
</li>
</ul>
<p className="dialog-message">Any cards that can't be matched are listed afterwards.</p>
<div className="dialog-actions">
<button onClick={() => setImportIntroOpen(false)}>Cancel</button>
<button className="primary" onClick={pickImportFile} autoFocus>
Choose file…
</button>
</div>
</DialogFrame>
)}
{importProgress && showImportProgress && (
<ProgressDialog
title="Importing cards…"
done={importProgress.done}
total={importProgress.total}
unit="lines"
/>
)}
{importError && (
<InfoDialog
title="Couldn't import that file"
message={importError}
onClose={() => setImportError(null)}
/>
)}
{importResult && (
<InfoDialog
title="Cards imported"