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

@@ -237,7 +237,10 @@ match any printing still imports the card itself.
or binder, either the `.txt` or the `.csv`. Both add to what you already own.
The CSV names each copy's exact printing and finish, so it's the better of
the two. Condition, language, prices and the other ManaBox fields aren't
kept. Proxies count as owned copies.
kept. Proxies count as owned copies. After you pick the file, the import asks
for a location to file the cards under, pre-filled with the file's name (a
ManaBox export is named after its binder). Leave it blank to keep cards'
current locations.
> The picker's dates and languages need card data from this version. Until you
> update it, printings are listed without them. Whenever your card data is

View File

@@ -456,15 +456,24 @@ impl Catalog {
/// Import a card list into the library: a plain-text list (ours, or
/// ManaBox's text export) or a ManaBox CSV export, told apart by
/// [`crate::manabox::is_manabox_csv`]. The whole import is one
/// transaction, so a failure part-way leaves the library as it was.
/// `on_progress(done, total)` is called after each line.
pub fn import_card_list(&self, text: &str, mut on_progress: impl FnMut(usize, usize)) -> Result<LibraryImport> {
/// [`crate::manabox::is_manabox_csv`]. A non-blank `location` becomes
/// the storage location of every card imported, replacing any it had,
/// as adding a card on Add Cards with a location does; a blank one
/// leaves locations alone. The whole import is one transaction, so a
/// failure part-way leaves the library as it was. `on_progress(done,
/// total)` is called after each line.
pub fn import_card_list(
&self,
text: &str,
location: &str,
mut on_progress: impl FnMut(usize, usize),
) -> Result<LibraryImport> {
let location = location.trim();
let tx = self.nested_tx()?;
let result = if crate::manabox::is_manabox_csv(text) {
self.import_manabox_rows(&crate::manabox::parse_manabox_csv(text)?, &mut on_progress)?
self.import_manabox_rows(&crate::manabox::parse_manabox_csv(text)?, location, &mut on_progress)?
} else {
self.import_library_lines(&parse_library_text(text), &mut on_progress)?
self.import_library_lines(&parse_library_text(text), location, &mut on_progress)?
};
tx.commit()?;
Ok(result)
@@ -479,14 +488,18 @@ impl Catalog {
/// new_total_quantity_owned)` per applied line, plus unresolved names.
pub fn import_library_list(&self, text: &str) -> Result<LibraryImport> {
let tx = self.nested_tx()?;
let result = self.import_library_lines(&parse_library_text(text), &mut |_, _| {})?;
let result = self.import_library_lines(&parse_library_text(text), "", &mut |_, _| {})?;
tx.commit()?;
Ok(result)
}
/// The import loop behind [`Catalog::import_card_list`] and
/// [`Catalog::import_library_list`]; `location` is set on each imported
/// card unless empty.
fn import_library_lines(
&self,
lines: &[DecklistLine],
location: &str,
on_progress: &mut dyn FnMut(usize, usize),
) -> Result<LibraryImport> {
let mut added = Vec::new();
@@ -498,6 +511,9 @@ impl Catalog {
continue;
};
let new_qty = self.add_owned(&oid, line.quantity)?;
if !location.is_empty() {
self.set_location(&oid, location)?;
}
if let Some(scryfall_id) = printing {
let wanted = line.printing.as_ref().and_then(|p| p.finish.clone());
self.add_imported_copies(&oid, &scryfall_id, wanted, line.quantity)?;
@@ -664,17 +680,41 @@ mod tests {
let cat = &fx.catalog;
let mut seen = Vec::new();
let (added, _) = cat
.import_card_list("2 Lightning Bolt\n1 Sol Ring\n", |done, total| seen.push((done, total)))
.import_card_list("2 Lightning Bolt\n1 Sol Ring\n", "", |done, total| seen.push((done, total)))
.unwrap();
assert_eq!(added.len(), 2);
assert_eq!(seen, vec![(0, 2), (1, 2), (2, 2)]);
let csv = "Name,Quantity,Scryfall ID\nSerra Angel,3,\n";
let (added, unresolved) = cat.import_card_list(csv, |_, _| {}).unwrap();
let (added, unresolved) = cat.import_card_list(csv, "", |_, _| {}).unwrap();
assert!(unresolved.is_empty());
assert_eq!(added, vec![("Serra Angel".to_string(), 3)]);
}
#[test]
fn import_card_list_files_imported_cards_under_a_location() {
let fx = test_support::make();
let cat = &fx.catalog;
cat.add_owned("sol-ring", 1).unwrap();
cat.set_location("sol-ring", "Box 1").unwrap();
cat.add_owned("serra-angel", 1).unwrap();
cat.set_location("serra-angel", "Box 1").unwrap();
let location = |oid: &str| {
cat.list_library().unwrap().into_iter().find(|c| c.card.oracle_id == oid).unwrap().location
};
// Blank leaves every location alone.
cat.import_card_list("1 Sol Ring\n", " ", |_, _| {}).unwrap();
assert_eq!(location("sol-ring"), "Box 1");
// Otherwise it's set on, and only on, the imported cards.
let csv = "Name,Quantity,Scryfall ID\nSol Ring,1,\nLightning Bolt,2,\n";
cat.import_card_list(csv, " Green Bulk ", |_, _| {}).unwrap();
assert_eq!(location("sol-ring"), "Green Bulk");
assert_eq!(location("lightning-bolt"), "Green Bulk");
assert_eq!(location("serra-angel"), "Box 1");
}
#[test]
fn writes_inside_an_uncommitted_nested_tx_are_rolled_back() {
let fx = test_support::make();

View File

@@ -180,14 +180,18 @@ impl Catalog {
/// [`Catalog::import_card_list`], which reports progress.
pub fn import_manabox_csv(&self, text: &str) -> Result<LibraryImport> {
let tx = self.nested_tx()?;
let result = self.import_manabox_rows(&parse_manabox_csv(text)?, &mut |_, _| {})?;
let result = self.import_manabox_rows(&parse_manabox_csv(text)?, "", &mut |_, _| {})?;
tx.commit()?;
Ok(result)
}
/// The import loop behind [`Catalog::import_manabox_csv`] and
/// [`Catalog::import_card_list`]; `location` is set on each imported
/// card unless empty.
pub(crate) fn import_manabox_rows(
&self,
rows: &[ManaboxRow],
location: &str,
on_progress: &mut dyn FnMut(usize, usize),
) -> Result<LibraryImport> {
let mut added = Vec::new();
@@ -199,6 +203,9 @@ impl Catalog {
continue;
};
let new_qty = self.add_owned(&oid, row.quantity)?;
if !location.is_empty() {
self.set_location(&oid, location)?;
}
if let Some(scryfall_id) = printing {
self.add_imported_copies(&oid, &scryfall_id, row.finish.clone(), row.quantity)?;
}

View File

@@ -441,11 +441,16 @@ pub struct LibraryImportProgress {
pub total: usize,
}
/// Import a card list (plain text or a ManaBox export) into the library.
/// Import a card list (plain text or a ManaBox export) into the library,
/// filing every imported card under `location` unless it's blank.
/// Runs on a blocking thread so the UI stays responsive, emitting
/// `library-import-progress` as it goes.
#[tauri::command]
pub async fn import_library_cards(app: AppHandle, path: String) -> Res<LibraryImportResult> {
pub async fn import_library_cards(
app: AppHandle,
path: String,
location: Option<String>,
) -> Res<LibraryImportResult> {
crate::sync::blocking(move || {
let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
let s = crate::sync::lock(&app);
@@ -453,7 +458,7 @@ pub async fn import_library_cards(app: AppHandle, path: String) -> Res<LibraryIm
let mut last_percent = None;
let (added, unresolved) = s
.catalog()?
.import_card_list(&text, |done, total| {
.import_card_list(&text, location.as_deref().unwrap_or(""), |done, total| {
let percent = (done * 100).checked_div(total).unwrap_or(100);
if last_percent != Some(percent) {
last_percent = Some(percent);

View File

@@ -517,6 +517,7 @@ export const api = {
invoke<DeckImportResult>("import_deck", { path, name }),
exportLibraryCards: (oracleIds: string[], path: string) =>
invoke<void>("export_library_cards", { oracleIds, path }),
importLibraryCards: (path: string) =>
invoke<LibraryImportResult>("import_library_cards", { path }),
/** `location`, unless blank, is set on every imported card. */
importLibraryCards: (path: string, location: string) =>
invoke<LibraryImportResult>("import_library_cards", { path, location }),
};

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>
);
}