From b61be5065b8ec68ca03dcbf3f56a0e6804f5da13 Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Fri, 30 Oct 2026 20:47:08 -0500 Subject: [PATCH] Library: remove selected cards from the library The bulk bar gets a Remove from library button that removes every copy of the selected cards. Decks keep them. Undo puts back the counts, locations and printing groups. --- README.md | 4 ++- crates/mtgc-core/src/printings.rs | 34 +++++++++++++++++++++++ src/views/LibraryBrowser.tsx | 45 +++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7e2ccf2..295986a 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,9 @@ installed app in place. row's thumbnail for a full-size preview that tracks the cursor. Rows show which decks a card is in and a warning (⚠) when it's assigned to more decks than you own. Toggle **List / Tiles** (top-right) to switch between the table and a grid - of full card images; the choice is remembered. + of full card images; the choice is remembered. Select rows (or tiles) to act + on several at once, including **Remove from library**, which removes every + copy; decks keep the cards, and one undo covers the whole batch. - **Card Search** — the same filters over *every* card, owned or not. Select rows (or tiles) and bulk-add them to a deck; one undo covers the whole batch. - Deck contents offer the same List / Tiles toggle and hover previews. diff --git a/crates/mtgc-core/src/printings.rs b/crates/mtgc-core/src/printings.rs index 126155f..bb9ed38 100644 --- a/crates/mtgc-core/src/printings.rs +++ b/crates/mtgc-core/src/printings.rs @@ -522,4 +522,38 @@ mod tests { // Card not in the deck. assert!(cat.set_deck_card_printing(d.id, "lightning-bolt", Some("print-lightning-bolt")).is_err()); } + + /// What the Library tab's "Remove from library" and its undo rely on. + #[test] + fn removing_a_card_keeps_its_decks_and_can_be_put_back() { + let fx = test_support::make(); + let cat = &fx.catalog; + add_sol_ring_printings(cat); + let groups = |cat: &Catalog| -> Vec<(String, String, i64)> { + let copies = cat.list_library_copies("sol-ring").unwrap(); + copies.into_iter().map(|c| (c.scryfall_id, c.finish, c.quantity)).collect() + }; + cat.set_owned("sol-ring", 5).unwrap(); + cat.set_location("sol-ring", "Binder 2").unwrap(); + cat.set_library_copy("sol-ring", "sol-2x2", "foil", 1).unwrap(); + cat.set_library_copy("sol-ring", "sol-c21", "nonfoil", 2).unwrap(); + let d = cat.create_deck("Rocks", None).unwrap(); + cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap(); + let before = groups(cat); + + cat.set_owned("sol-ring", 0).unwrap(); + assert!(groups(cat).is_empty(), "going to 0 drops the groups"); + assert!(cat.list_library().unwrap().iter().all(|c| c.card.oracle_id != "sol-ring")); + assert_eq!(cat.deck_contents(d.id).unwrap().len(), 1, "the deck keeps it"); + + cat.set_owned("sol-ring", 5).unwrap(); + cat.set_location("sol-ring", "Binder 2").unwrap(); + for (scryfall_id, finish, quantity) in &before { + cat.set_library_copy("sol-ring", scryfall_id, finish, *quantity).unwrap(); + } + assert_eq!(groups(cat), before); + let card = cat.list_library().unwrap().into_iter().find(|c| c.card.oracle_id == "sol-ring").unwrap(); + assert_eq!(card.quantity_owned, 5); + assert_eq!(card.location, "Binder 2"); + } } diff --git a/src/views/LibraryBrowser.tsx b/src/views/LibraryBrowser.tsx index 9c9aa0d..c970500 100644 --- a/src/views/LibraryBrowser.tsx +++ b/src/views/LibraryBrowser.tsx @@ -268,6 +268,43 @@ export function LibraryBrowser({ } } + /** + * Remove every copy of the selected cards from the library. Decks keep + * them, the same as decrementing a card to 0. + * + * Going to 0 also drops each card's printing groups, so those are read + * first for the "Undo" to put back. A group whose printing has left the card + * database can't be recreated; its copies come back unspecified. + */ + async function bulkRemoveFromLibrary() { + const targets = rows.filter((r) => selected.has(r.oracle_id) && r.quantity_owned > 0); + if (targets.length === 0) return; + setBulkBusy(true); + try { + const copies = await Promise.all(targets.map((r) => api.listLibraryCopies(r.oracle_id))); + await Promise.all(targets.map((r) => api.setOwned(r.oracle_id, 0))); + setRefreshKey((k) => k + 1); + onOfferUndo( + `Removed ${targets.length} card${targets.length === 1 ? "" : "s"} from your library.`, + async () => { + await Promise.all( + targets.map(async (r, i) => { + await api.setOwned(r.oracle_id, r.quantity_owned); + await api.setLocation(r.oracle_id, r.location); + // One at a time: a group's position sets which is trimmed first. + for (const c of copies[i].filter((c) => c.printing)) { + await api.setLibraryCopy(r.oracle_id, c.scryfall_id, c.finish, c.quantity); + } + }), + ); + setRefreshKey((k) => k + 1); + }, + ); + } finally { + setBulkBusy(false); + } + } + async function exportSelected() { const targets = rows.filter((r) => selected.has(r.oracle_id)).map((r) => r.oracle_id); if (targets.length === 0) return; @@ -534,6 +571,14 @@ export function LibraryBrowser({ > Export selected +