Library import asks for a location to file the cards under

After picking a file, Import cards… asks where the cards are kept,
pre-filled with the file's name (a ManaBox export is named after its
binder). A location is set on every imported card, replacing any it had,
as Add Cards does; blank leaves locations alone. It's applied inside the
import's single transaction.
This commit is contained in:
2026-10-17 21:58:44 -05:00
parent 1641d9aa47
commit 4b651b5108
6 changed files with 149 additions and 19 deletions

View File

@@ -101,6 +101,8 @@ export function LibraryBrowser({
const [importError, setImportError] = useState<string | null>(null);
// The "what can I import?" explainer shown before the file picker.
const [importIntroOpen, setImportIntroOpen] = useState(false);
// The file picked to import, while asking where its cards are kept.
const [importPath, setImportPath] = useState<string | null>(null);
// 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
@@ -322,14 +324,18 @@ export function LibraryBrowser({
title: "Import library cards",
filters: [{ name: "Card list (decklist or ManaBox export)", extensions: ["txt", "csv"] }],
});
if (typeof path !== "string") return;
if (typeof path === "string") setImportPath(path);
}
async function runImport(path: string, location: string) {
setImportPath(null);
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));
setImportResult(await api.importLibraryCards(path, location));
} catch (err) {
setImportError(String(err));
} finally {
@@ -603,7 +609,10 @@ export function LibraryBrowser({
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>
<p className="dialog-message">
After you choose a file, you can file its cards under a location, like the binder they
came from. 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>
@@ -613,6 +622,14 @@ export function LibraryBrowser({
</DialogFrame>
)}
{importPath && (
<ImportLocationDialog
path={importPath}
onImport={(location) => runImport(importPath, location)}
onCancel={() => setImportPath(null)}
/>
)}
{importProgress && showImportProgress && (
<ProgressDialog
title="Importing cards…"
@@ -795,3 +812,60 @@ export function LibraryBrowser({
</div>
);
}
/** A picked file's name without its folder or extension — a ManaBox export
* is named after its binder ("Green Bulk.csv"). Copes with Android content
* URIs, whose last segment is a percent-encoded "primary:Download/x.csv". */
function fileStem(path: string): string {
let name = path;
try {
name = decodeURIComponent(path);
} catch {
// Not percent-encoded after all; use it as it is.
}
name = name.split(/[/\\:]/).pop() ?? "";
return name.replace(/\.[^.]*$/, "").trim();
}
/** Asks where an import's cards are kept, pre-filled with the file's name.
* Blank imports them without touching any card's location. */
function ImportLocationDialog({
path,
onImport,
onCancel,
}: {
path: string;
onImport: (location: string) => void;
onCancel: () => void;
}) {
const [value, setValue] = useState(() => fileStem(path));
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => inputRef.current?.select(), []);
const submit = () => onImport(value.trim());
return (
<DialogFrame title="Where are these cards?" onCancel={onCancel}>
<label className="dialog-field">
<span className="muted">Location</span>
<input
ref={inputRef}
autoFocus
value={value}
placeholder="e.g. Box 1"
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
/>
</label>
<p className="dialog-message">
Every imported card is filed under this location, replacing any it already had. Leave it
blank to keep cards' current locations.
</p>
<div className="dialog-actions">
<button onClick={onCancel}>Cancel</button>
<button className="primary" onClick={submit}>
Import
</button>
</div>
</DialogFrame>
);
}