From 4b651b510800f3c47d05b84d86b0dd5587cb0862 Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Sat, 17 Oct 2026 21:58:44 -0500 Subject: [PATCH] Library import asks for a location to file the cards under MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 5 +- crates/mtgc-core/src/decklist.rs | 58 +++++++++++++++++++---- crates/mtgc-core/src/manabox.rs | 9 +++- src-tauri/src/commands.rs | 11 +++-- src/api.ts | 5 +- src/views/LibraryBrowser.tsx | 80 ++++++++++++++++++++++++++++++-- 6 files changed, 149 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index f42f27e..6875405 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/mtgc-core/src/decklist.rs b/crates/mtgc-core/src/decklist.rs index 86e6898..3f8803e 100644 --- a/crates/mtgc-core/src/decklist.rs +++ b/crates/mtgc-core/src/decklist.rs @@ -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 { + /// [`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 { + 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 { 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 { 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(); diff --git a/crates/mtgc-core/src/manabox.rs b/crates/mtgc-core/src/manabox.rs index 41ab343..4cd6c0f 100644 --- a/crates/mtgc-core/src/manabox.rs +++ b/crates/mtgc-core/src/manabox.rs @@ -180,14 +180,18 @@ impl Catalog { /// [`Catalog::import_card_list`], which reports progress. pub fn import_manabox_csv(&self, text: &str) -> Result { 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 { 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)?; } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 316cd3b..0398b19 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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 { +pub async fn import_library_cards( + app: AppHandle, + path: String, + location: Option, +) -> Res { 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("import_deck", { path, name }), exportLibraryCards: (oracleIds: string[], path: string) => invoke("export_library_cards", { oracleIds, path }), - importLibraryCards: (path: string) => - invoke("import_library_cards", { path }), + /** `location`, unless blank, is set on every imported card. */ + importLibraryCards: (path: string, location: string) => + invoke("import_library_cards", { path, location }), }; diff --git a/src/views/LibraryBrowser.tsx b/src/views/LibraryBrowser.tsx index 8cbe1d4..315bccd 100644 --- a/src/views/LibraryBrowser.tsx +++ b/src/views/LibraryBrowser.tsx @@ -101,6 +101,8 @@ export function LibraryBrowser({ const [importError, setImportError] = useState(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(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. -

Any cards that can't be matched are listed afterwards.

+

+ 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. +

+ +
+ + ); +}