diff --git a/crates/mtgc-core/src/decklist.rs b/crates/mtgc-core/src/decklist.rs new file mode 100644 index 0000000..b765738 --- /dev/null +++ b/crates/mtgc-core/src/decklist.rs @@ -0,0 +1,474 @@ +//! Plain-text decklist import/export: parsing and formatting for decks +//! (with Commander/Deck/Maybeboard sections) and flat library card lists. +//! +//! Deck export format: +//! ```text +//! // mtgc deck export +//! // Format: Commander +//! +//! Commander +//! 1 Krenko, Mob Boss +//! +//! Deck +//! 1 Sol Ring +//! 4 Lightning Bolt +//! +//! Maybeboard +//! 1 Some Card +//! ``` +//! `//` lines are comments (`// Format: X` is recognized specially to +//! restore the deck's format on import). Section headers are +//! case-insensitive; `Sideboard` is accepted as an input alias for +//! `Maybeboard` (compatibility with lists copied from other tools), but +//! export always emits `Maybeboard`. Cards before any header, or under +//! `Deck`, are mainboard. Quantities accept a bare integer (`4 Foo`) or an +//! `Nx`/`N×` prefix (`4x Foo`). Unrecognized lines are silently skipped, +//! never a hard error — see [`parse_deck_text`]. +//! +//! Library export format is the same comment/quantity-name line convention +//! with no sections — see [`parse_library_text`]. + +use rusqlite::{params, OptionalExtension}; + +use crate::db::REF_DB; +use crate::error::Result; +use crate::model::{Deck, DeckEntry}; +use crate::Catalog; + +/// One ` ` line from a decklist, prior to name resolution. +#[derive(Debug, Clone, PartialEq)] +pub struct DecklistLine { + pub quantity: i64, + pub name: String, +} + +/// The structured result of parsing deck-export text. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ParsedDeckText { + /// From a `// Format: X` comment, if present. + pub format: Option, + /// Commander section lines. Normally 0 or 1 — see + /// [`Catalog::import_deck`] for how extra lines are handled. + pub commander: Vec, + pub mainboard: Vec, + pub maybeboard: Vec, +} + +#[derive(Clone, Copy, PartialEq)] +enum Section { + Mainboard, + Commander, + Maybeboard, +} + +/// Parse deck-export text into structured lines. Lenient: blank lines are +/// skipped, `//` lines are comments, section headers are case-insensitive, +/// and lines that don't parse as ` ` are silently skipped rather +/// than erroring the whole import. +pub fn parse_deck_text(text: &str) -> ParsedDeckText { + let mut parsed = ParsedDeckText::default(); + let mut section = Section::Mainboard; + + for raw_line in text.lines() { + let line = raw_line.trim(); + if line.is_empty() { + continue; + } + if let Some(rest) = line.strip_prefix("//") { + let rest = rest.trim(); + if let Some(fmt) = rest + .strip_prefix("Format:") + .or_else(|| rest.strip_prefix("format:")) + { + parsed.format = Some(fmt.trim().to_string()); + } + continue; + } + match line.to_lowercase().as_str() { + "commander" => { + section = Section::Commander; + continue; + } + "deck" => { + section = Section::Mainboard; + continue; + } + "maybeboard" | "sideboard" => { + section = Section::Maybeboard; + continue; + } + _ => {} + } + if let Some(card_line) = parse_card_line(line) { + match section { + Section::Mainboard => parsed.mainboard.push(card_line), + Section::Commander => parsed.commander.push(card_line), + Section::Maybeboard => parsed.maybeboard.push(card_line), + } + } + } + + parsed +} + +/// Parse a single ` ` line. Accepts a bare integer quantity +/// (`4 Lightning Bolt`) or an `Nx`/`N×` prefix (`4x Lightning Bolt`). +/// Returns `None` (skip, don't error) if the line doesn't start with a +/// recognizable positive quantity or has no name after it. +fn parse_card_line(line: &str) -> Option { + let (head, rest) = line.split_once(char::is_whitespace)?; + let head_lower = head.to_lowercase(); + let qty_str = head_lower + .strip_suffix('x') + .or_else(|| head_lower.strip_suffix('×')) + .unwrap_or(&head_lower); + let quantity: i64 = qty_str.parse().ok()?; + if quantity <= 0 { + return None; + } + let name = rest.trim(); + if name.is_empty() { + return None; + } + Some(DecklistLine { + quantity, + name: name.to_string(), + }) +} + +/// Parse library-export text into ` ` lines (no sections). Same +/// leniency rules as [`parse_deck_text`]. +pub fn parse_library_text(text: &str) -> Vec { + text.lines() + .map(str::trim) + .filter(|l| !l.is_empty() && !l.starts_with("//")) + .filter_map(parse_card_line) + .collect() +} + +/// Render a deck (metadata + contents) as export text. Empty sections (no +/// commander, no maybeboard entries) are omitted entirely. The +/// `// Format: X` comment is emitted only when `deck.format` is non-empty. +pub fn format_deck_text(deck: &Deck, contents: &[DeckEntry]) -> String { + let mut out = String::from("// mtgc deck export\n"); + if !deck.format.is_empty() { + out.push_str(&format!("// Format: {}\n", deck.format)); + } + + let commander: Vec<_> = contents.iter().filter(|e| e.is_commander).collect(); + let maybeboard: Vec<_> = contents.iter().filter(|e| e.is_maybeboard).collect(); + let mainboard: Vec<_> = contents + .iter() + .filter(|e| !e.is_commander && !e.is_maybeboard) + .collect(); + + if !commander.is_empty() { + out.push_str("\nCommander\n"); + for e in &commander { + out.push_str(&format!("{} {}\n", e.quantity, e.card.name)); + } + } + if !mainboard.is_empty() { + out.push_str("\nDeck\n"); + for e in &mainboard { + out.push_str(&format!("{} {}\n", e.quantity, e.card.name)); + } + } + if !maybeboard.is_empty() { + out.push_str("\nMaybeboard\n"); + for e in &maybeboard { + out.push_str(&format!("{} {}\n", e.quantity, e.card.name)); + } + } + + out +} + +/// Render `(name, quantity)` pairs as library-export text. +pub fn format_library_text(entries: &[(String, i64)]) -> String { + let mut out = String::from("// mtgc library export\n"); + for (name, qty) in entries { + out.push_str(&format!("{qty} {name}\n")); + } + out +} + +impl Catalog { + /// Case-insensitive exact-name lookup against the reference db's + /// precomputed `name_lower` column. No fuzzy matching — an unresolved + /// name is reported back to the caller rather than guessed at, so a + /// typo never silently imports the wrong card. + pub fn find_oracle_id_by_name(&self, name: &str) -> Result> { + Ok(self + .conn + .query_row( + &format!("SELECT oracle_id FROM {REF_DB}.oracle_cards WHERE name_lower = ?1 LIMIT 1"), + params![name.trim().to_lowercase()], + |r| r.get(0), + ) + .optional()?) + } + + /// Import a decklist as a brand-new deck named `name`. Always creates a + /// new deck — never merges into an existing one. Best-effort: card + /// names that don't resolve are skipped (not a hard failure) and + /// collected into the returned list, alongside the created `Deck`. + /// + /// Only the first resolved Commander-section line becomes the actual + /// commander; any further Commander-section lines are folded into the + /// mainboard at their given quantity rather than dropped or erroring. + pub fn import_deck(&self, name: &str, text: &str) -> Result<(Deck, Vec)> { + let parsed = parse_deck_text(text); + let deck = self.create_deck(name, None)?; + if let Some(fmt) = &parsed.format { + self.set_deck_format(deck.id, fmt)?; + } + + let mut unresolved = Vec::new(); + let mut commander_oracle_id: Option = None; + + for (i, line) in parsed.commander.iter().enumerate() { + match self.find_oracle_id_by_name(&line.name)? { + Some(oid) => { + let is_first = i == 0; + self.set_deck_card(deck.id, &oid, line.quantity, is_first, false)?; + if is_first { + commander_oracle_id = Some(oid); + } + } + None => unresolved.push(line.name.clone()), + } + } + if let Some(oid) = &commander_oracle_id { + self.set_deck_commander(deck.id, Some(oid))?; + } + + for line in &parsed.mainboard { + match self.find_oracle_id_by_name(&line.name)? { + Some(oid) => self.set_deck_card(deck.id, &oid, line.quantity, false, false)?, + None => unresolved.push(line.name.clone()), + } + } + for line in &parsed.maybeboard { + match self.find_oracle_id_by_name(&line.name)? { + Some(oid) => self.set_deck_card(deck.id, &oid, line.quantity, false, true)?, + None => unresolved.push(line.name.clone()), + } + } + + let deck = self + .get_deck(deck.id)? + .expect("just-imported deck must exist"); + Ok((deck, unresolved)) + } + + /// Render a deck's current contents as export text. + pub fn export_deck_text(&self, deck_id: i64) -> Result { + let deck = self + .get_deck(deck_id)? + .ok_or_else(|| crate::error::CoreError::Other(format!("deck {deck_id} not found")))?; + let contents = self.deck_contents(deck_id)?; + Ok(format_deck_text(&deck, &contents)) + } + + /// Import a plain-text library list, adding each resolved card's + /// quantity to existing ownership (additive via [`Catalog::add_owned`]). + /// Returns `(name, new_total_quantity_owned)` per applied line, plus + /// unresolved names. + pub fn import_library_list(&self, text: &str) -> Result<(Vec<(String, i64)>, Vec)> { + let lines = parse_library_text(text); + let mut added = Vec::new(); + let mut unresolved = Vec::new(); + for line in &lines { + match self.find_oracle_id_by_name(&line.name)? { + Some(oid) => { + let new_qty = self.add_owned(&oid, line.quantity)?; + added.push((line.name.clone(), new_qty)); + } + None => unresolved.push(line.name.clone()), + } + } + Ok((added, unresolved)) + } + + /// Render the given owned cards (name + current quantity_owned) as + /// library-export text. + pub fn export_library_text(&self, oracle_ids: &[String]) -> Result { + let mut entries = Vec::with_capacity(oracle_ids.len()); + for oid in oracle_ids { + let row: Option<(String, i64)> = self + .conn + .query_row( + &format!( + "SELECT oc.name, lc.quantity_owned FROM {REF_DB}.oracle_cards oc + JOIN library_cards lc ON lc.oracle_id = oc.oracle_id + WHERE oc.oracle_id = ?1" + ), + params![oid], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional()?; + if let Some(entry) = row { + entries.push(entry); + } + } + entries.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); + Ok(format_library_text(&entries)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support; + + #[test] + fn parse_card_line_accepts_bare_and_x_prefixed_quantities() { + assert_eq!( + parse_card_line("4 Lightning Bolt"), + Some(DecklistLine { quantity: 4, name: "Lightning Bolt".into() }) + ); + assert_eq!( + parse_card_line("4x Lightning Bolt"), + Some(DecklistLine { quantity: 4, name: "Lightning Bolt".into() }) + ); + assert_eq!( + parse_card_line("4× Lightning Bolt"), + Some(DecklistLine { quantity: 4, name: "Lightning Bolt".into() }) + ); + assert_eq!(parse_card_line("not a card line"), None); + assert_eq!(parse_card_line("0 Lightning Bolt"), None); + } + + #[test] + fn parse_deck_text_tolerates_blanks_comments_and_sideboard_alias() { + let text = "// mtgc deck export\n// Format: Commander\n\nCommander\n1 Krenko, Mob Boss\n\nDeck\n1 Sol Ring\n\nSideboard\n1 Some Card\n"; + let parsed = parse_deck_text(text); + assert_eq!(parsed.format.as_deref(), Some("Commander")); + assert_eq!( + parsed.commander, + vec![DecklistLine { quantity: 1, name: "Krenko, Mob Boss".into() }] + ); + assert_eq!(parsed.mainboard, vec![DecklistLine { quantity: 1, name: "Sol Ring".into() }]); + assert_eq!( + parsed.maybeboard, + vec![DecklistLine { quantity: 1, name: "Some Card".into() }] + ); + } + + #[test] + fn parse_deck_text_defaults_unheaded_lines_to_mainboard() { + let parsed = parse_deck_text("4 Lightning Bolt\n1 Sol Ring\n"); + assert_eq!(parsed.mainboard.len(), 2); + assert!(parsed.commander.is_empty()); + assert!(parsed.maybeboard.is_empty()); + } + + #[test] + fn format_deck_text_omits_empty_sections() { + let deck = Deck { + id: 1, + name: "Test".into(), + format: String::new(), + commander_oracle_id: None, + notes: String::new(), + }; + let text = format_deck_text(&deck, &[]); + assert_eq!(text, "// mtgc deck export\n"); + } + + #[test] + fn import_deck_resolves_names_sets_format_and_commander_and_reports_unresolved() { + let fx = test_support::make(); + let cat = &fx.catalog; + let text = "// Format: Commander\n\nCommander\n1 Serra Angel\n\nDeck\n4 Lightning Bolt\n1 Totally Fake Card\n\nMaybeboard\n1 Sol Ring\n"; + + let (deck, unresolved) = cat.import_deck("Imported", text).unwrap(); + assert_eq!(deck.format, "Commander"); + assert_eq!(deck.commander_oracle_id.as_deref(), Some("serra-angel")); + assert_eq!(unresolved, vec!["Totally Fake Card".to_string()]); + + let contents = cat.deck_contents(deck.id).unwrap(); + let bolt = contents.iter().find(|e| e.card.oracle_id == "lightning-bolt").unwrap(); + assert_eq!(bolt.quantity, 4); + assert!(!bolt.is_commander); + assert!(!bolt.is_maybeboard); + + let sol = contents.iter().find(|e| e.card.oracle_id == "sol-ring").unwrap(); + assert!(sol.is_maybeboard); + + let angel = contents.iter().find(|e| e.card.oracle_id == "serra-angel").unwrap(); + assert!(angel.is_commander); + } + + #[test] + fn import_deck_extra_commander_lines_fold_into_mainboard() { + let fx = test_support::make(); + let cat = &fx.catalog; + let text = "Commander\n1 Serra Angel\n1 Sol Ring\n"; + let (deck, unresolved) = cat.import_deck("Two Commanders", text).unwrap(); + assert!(unresolved.is_empty()); + + let contents = cat.deck_contents(deck.id).unwrap(); + let angel = contents.iter().find(|e| e.card.oracle_id == "serra-angel").unwrap(); + assert!(angel.is_commander); + let sol = contents.iter().find(|e| e.card.oracle_id == "sol-ring").unwrap(); + assert!(!sol.is_commander); + assert!(!sol.is_maybeboard); + } + + #[test] + fn export_deck_text_round_trips_through_parser() { + let fx = test_support::make(); + let cat = &fx.catalog; + let deck = cat.create_deck("Round Trip", Some("serra-angel")).unwrap(); + cat.set_deck_format(deck.id, "Commander").unwrap(); + cat.set_deck_card(deck.id, "serra-angel", 1, true, false).unwrap(); + cat.set_deck_card(deck.id, "lightning-bolt", 4, false, false).unwrap(); + cat.set_deck_card(deck.id, "sol-ring", 1, false, true).unwrap(); + + let text = cat.export_deck_text(deck.id).unwrap(); + let parsed = parse_deck_text(&text); + assert_eq!(parsed.format.as_deref(), Some("Commander")); + assert_eq!( + parsed.commander, + vec![DecklistLine { quantity: 1, name: "Serra Angel".into() }] + ); + assert_eq!( + parsed.mainboard, + vec![DecklistLine { quantity: 4, name: "Lightning Bolt".into() }] + ); + assert_eq!( + parsed.maybeboard, + vec![DecklistLine { quantity: 1, name: "Sol Ring".into() }] + ); + } + + #[test] + fn import_library_list_adds_to_existing_ownership_and_reports_unresolved() { + let fx = test_support::make(); + let cat = &fx.catalog; + cat.add_owned("lightning-bolt", 2).unwrap(); + + let text = "2 Lightning Bolt\n1 Sol Ring\n1 Not A Real Card\n"; + let (added, unresolved) = cat.import_library_list(text).unwrap(); + + assert_eq!(unresolved, vec!["Not A Real Card".to_string()]); + let bolt_added = added.iter().find(|(n, _)| n == "Lightning Bolt").unwrap(); + assert_eq!(bolt_added.1, 4); // 2 existing + 2 imported + let sol_added = added.iter().find(|(n, _)| n == "Sol Ring").unwrap(); + assert_eq!(sol_added.1, 1); + } + + #[test] + fn export_library_text_formats_only_requested_cards() { + let fx = test_support::make(); + let cat = &fx.catalog; + cat.add_owned("lightning-bolt", 3).unwrap(); + cat.add_owned("sol-ring", 1).unwrap(); + + let text = cat.export_library_text(&["lightning-bolt".to_string()]).unwrap(); + assert!(text.contains("3 Lightning Bolt")); + assert!(!text.contains("Sol Ring")); + } +} diff --git a/crates/mtgc-core/src/decks.rs b/crates/mtgc-core/src/decks.rs index a45b5d9..852c40b 100644 --- a/crates/mtgc-core/src/decks.rs +++ b/crates/mtgc-core/src/decks.rs @@ -4,7 +4,7 @@ use rusqlite::{params, OptionalExtension}; use crate::db::REF_DB; use crate::error::Result; -use crate::model::{CardRef, ColorSet, Deck, DeckEntry}; +use crate::model::{CardRef, ColorSet, Deck, DeckEntry, Legalities}; use crate::Catalog; impl Catalog { @@ -114,8 +114,8 @@ impl Catalog { let mut stmt = self.conn.prepare(&format!( "SELECT dc.quantity, dc.is_commander, COALESCE(lc.quantity_owned, 0), oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity, - oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless, {price}, - dc.is_maybeboard + oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless, + oc.legalities, {price}, dc.is_maybeboard FROM deck_cards dc JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id LEFT JOIN library_cards lc ON lc.oracle_id = dc.oracle_id @@ -142,9 +142,10 @@ impl Catalog { back_image_uri: r.get::<_, Option>(10)?.unwrap_or_default(), produced_mana: ColorSet(r.get::<_, i64>(11)? as u8), produces_colorless: r.get::<_, i64>(12)? != 0, - price_usd: r.get::<_, Option>(13)?.unwrap_or_default(), + legalities: Legalities(r.get::<_, i64>(13)?), + price_usd: r.get::<_, Option>(14)?.unwrap_or_default(), }, - is_maybeboard: r.get::<_, i64>(14)? != 0, + is_maybeboard: r.get::<_, i64>(15)? != 0, }) })? .collect::, _>>()?; @@ -236,6 +237,33 @@ mod tests { assert!(!row.is_over_assigned()); } + #[test] + fn legalities_are_plumbed_through_deck_contents() { + use crate::model::LegalStatus; + + let fx = test_support::make(); + let cat = &fx.catalog; + // 2 (Legal) in the "commander" slot (bit offset 12, per LEGALITY_FORMATS). + cat.connection() + .execute( + "UPDATE ref.oracle_cards SET legalities = 4096 WHERE oracle_id = 'sol-ring'", + [], + ) + .unwrap(); + + let d = cat.create_deck("Legality Check", None).unwrap(); + cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap(); + cat.set_deck_card(d.id, "llanowar-elves", 1, false, false).unwrap(); + + let contents = cat.deck_contents(d.id).unwrap(); + let sol = contents.iter().find(|e| e.card.oracle_id == "sol-ring").unwrap(); + assert_eq!(sol.card.legalities.status_for("Commander"), LegalStatus::Legal); + + // Fixture cards default to no legality data at all. + let elves = contents.iter().find(|e| e.card.oracle_id == "llanowar-elves").unwrap(); + assert_eq!(elves.card.legalities.status_for("Commander"), LegalStatus::NotLegal); + } + #[test] fn over_assignment_detected_across_decks() { let fx = test_support::make(); diff --git a/crates/mtgc-core/src/filters.rs b/crates/mtgc-core/src/filters.rs index de5230b..ec787d3 100644 --- a/crates/mtgc-core/src/filters.rs +++ b/crates/mtgc-core/src/filters.rs @@ -19,7 +19,7 @@ impl Catalog { let mut sql = format!( "SELECT lc.oracle_id, lc.quantity_owned, lc.location, oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back}, - oc.produced_mana, oc.produces_colorless, {price} + oc.produced_mana, oc.produces_colorless, oc.legalities, {price} FROM library_cards lc JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id WHERE lc.quantity_owned > 0", diff --git a/crates/mtgc-core/src/ingest.rs b/crates/mtgc-core/src/ingest.rs index a3fe730..266fd9f 100644 --- a/crates/mtgc-core/src/ingest.rs +++ b/crates/mtgc-core/src/ingest.rs @@ -19,7 +19,7 @@ use serde::Deserialize; use crate::db::open_reference_for_build; use crate::error::{CoreError, Result}; -use crate::model::ColorSet; +use crate::model::{ColorSet, Legalities}; const BULK_INDEX_URL: &str = "https://api.scryfall.com/bulk-data"; const USER_AGENT: &str = "mtgc/0.1 (personal library catalog)"; @@ -434,6 +434,10 @@ struct ScryOracle { /// top-level-only Scryfall field — not per-face, even on DFCs. #[serde(default)] produced_mana: Option>, + /// Format-slug -> status-string (e.g. "legal"/"not_legal"/"banned"/ + /// "restricted"). See [`crate::model::Legalities`]. + #[serde(default)] + legalities: std::collections::HashMap, } #[derive(Deserialize)] @@ -453,8 +457,8 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken) let mut ins_card = conn.prepare( "INSERT OR IGNORE INTO oracle_cards (oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text, - produced_mana,produces_colorless) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)", + produced_mana,produces_colorless,legalities) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)", )?; let mut ins_fts = conn.prepare("INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)")?; @@ -533,6 +537,8 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken) let produced_mana = ColorSet::from_letters(produced); let produces_colorless = produced.iter().any(|s| s.eq_ignore_ascii_case("C")); + let legalities = Legalities::from_scryfall(&c.legalities); + let changed = ins_card.execute(params![ oracle_id, c.name, @@ -544,6 +550,7 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken) oracle_text, produced_mana.0 as i64, produces_colorless as i64, + legalities.0, ])?; // Only emit satellite rows the first time we see this oracle_id. if changed == 1 { @@ -922,6 +929,48 @@ mod tests { assert_eq!(vanilla_colorless, 0); // no produced_mana field at all } + #[test] + fn captures_legalities() { + let oracle = r#"[ + {"oracle_id":"oid-banned","name":"Balance","cmc":1.0,"color_identity":["W"],"keywords":[], + "legalities":{"standard":"not_legal","modern":"banned","legacy":"banned","vintage":"restricted","commander":"banned"}}, + {"oracle_id":"oid-legal","name":"Lightning Bolt","cmc":1.0,"color_identity":["R"],"keywords":[], + "legalities":{"modern":"legal","legacy":"legal","vintage":"legal","pauper":"legal"}}, + {"oracle_id":"oid-nolegal","name":"Some Test Card","cmc":1.0,"color_identity":[],"keywords":[]} + ]"#; + + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("reference.db"); + let conn = open_reference_for_build(&dest).unwrap(); + + load_oracle_cards(&conn, oracle.as_bytes(), &CancelToken::new()).unwrap(); + + let fetch = |id: &str| -> Legalities { + Legalities( + conn.query_row( + "SELECT legalities FROM oracle_cards WHERE oracle_id=?1", + params![id], + |r| r.get(0), + ) + .unwrap(), + ) + }; + + let balance = fetch("oid-banned"); + assert_eq!(balance.status_for("Vintage"), crate::model::LegalStatus::Restricted); + assert_eq!(balance.status_for("Modern"), crate::model::LegalStatus::Banned); + assert_eq!(balance.status_for("Standard"), crate::model::LegalStatus::NotLegal); + + let bolt = fetch("oid-legal"); + assert_eq!(bolt.status_for("Modern"), crate::model::LegalStatus::Legal); + assert_eq!(bolt.status_for("Standard"), crate::model::LegalStatus::NotLegal); // absent key + + let vanilla = fetch("oid-nolegal"); + for (_, label) in crate::model::LEGALITY_FORMATS { + assert_eq!(vanilla.status_for(label), crate::model::LegalStatus::NotLegal); // no legalities object at all + } + } + #[test] fn captures_back_face_image_for_double_faced_cards() { // Transform/MDFC cards: two `card_faces`, each with its own image. diff --git a/crates/mtgc-core/src/lib.rs b/crates/mtgc-core/src/lib.rs index d81c772..1e33940 100644 --- a/crates/mtgc-core/src/lib.rs +++ b/crates/mtgc-core/src/lib.rs @@ -6,6 +6,7 @@ //! unit-testable and reusable when the app is ported to mobile. pub mod db; +pub mod decklist; pub mod decks; pub mod error; pub mod filters; @@ -20,7 +21,8 @@ use rusqlite::Connection; pub use error::{CoreError, Result}; pub use model::{ - CardRef, ColorSet, Deck, DeckEntry, LibraryCard, LibraryFacets, LibraryFilter, LibraryStats, + CardRef, ColorSet, Deck, DeckEntry, LegalStatus, Legalities, LibraryCard, LibraryFacets, + LibraryFilter, LibraryStats, }; pub use search::NameIndex; diff --git a/crates/mtgc-core/src/library.rs b/crates/mtgc-core/src/library.rs index 19e0a8f..80643d7 100644 --- a/crates/mtgc-core/src/library.rs +++ b/crates/mtgc-core/src/library.rs @@ -6,13 +6,13 @@ use rusqlite::{params, OptionalExtension}; use crate::db::REF_DB; use crate::error::Result; -use crate::model::{CardRef, ColorSet, LibraryCard, LibraryFacets, LibraryStats, WUBRG}; +use crate::model::{CardRef, ColorSet, Legalities, LibraryCard, LibraryFacets, LibraryStats, WUBRG}; use crate::Catalog; /// Map a row shaped `(oracle_id, quantity_owned, location, name, mana_value, /// colors, color_identity, type_line, image_uri, back_image_uri, produced_mana, -/// produces_colorless, price_usd)` into its library parts. Shared by -/// [`Catalog::list_library`] and the criteria search. +/// produces_colorless, legalities, price_usd)` into its library parts. Shared +/// by [`Catalog::list_library`] and the criteria search. pub(crate) fn row_to_library_parts( r: &rusqlite::Row, ) -> rusqlite::Result<(String, i64, String, CardRef)> { @@ -32,7 +32,8 @@ pub(crate) fn row_to_library_parts( back_image_uri: r.get::<_, Option>(9)?.unwrap_or_default(), produced_mana: ColorSet(r.get::<_, i64>(10)? as u8), produces_colorless: r.get::<_, i64>(11)? != 0, - price_usd: r.get::<_, Option>(12)?.unwrap_or_default(), + legalities: Legalities(r.get::<_, i64>(12)?), + price_usd: r.get::<_, Option>(13)?.unwrap_or_default(), }, )) } @@ -75,7 +76,7 @@ impl Catalog { let mut stmt = self.conn.prepare(&format!( "SELECT lc.oracle_id, lc.quantity_owned, lc.location, oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back}, - oc.produced_mana, oc.produces_colorless, {price} + oc.produced_mana, oc.produces_colorless, oc.legalities, {price} FROM library_cards lc JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id WHERE lc.quantity_owned > 0 diff --git a/crates/mtgc-core/src/model.rs b/crates/mtgc-core/src/model.rs index c9ac275..7f75551 100644 --- a/crates/mtgc-core/src/model.rs +++ b/crates/mtgc-core/src/model.rs @@ -75,6 +75,73 @@ impl ColorSet { } } +/// The 8 formats this app tracks legality for, in a fixed order that defines +/// their bit position within [`Legalities`] (2 bits each, in order). This +/// order must never change once shipped — it's baked into every ingested +/// reference.db. `.0` is the Scryfall `legalities` JSON key; `.1` is the +/// display label used throughout the app (matches DeckView's +/// `FORMATS`/`COMMANDER_FORMATS`). +pub const LEGALITY_FORMATS: [(&str, &str); 8] = [ + ("standard", "Standard"), + ("pioneer", "Pioneer"), + ("modern", "Modern"), + ("legacy", "Legacy"), + ("vintage", "Vintage"), + ("pauper", "Pauper"), + ("commander", "Commander"), + ("brawl", "Brawl"), +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LegalStatus { + NotLegal, + Legal, + Banned, + Restricted, +} + +/// Per-format legality, encoded as 2 bits/format x 8 formats packed into one +/// i64 (see [`LEGALITY_FORMATS`] for bit-position order). Mirrors +/// `ColorSet`'s bitmask-newtype pattern but with 2-bit fields instead of +/// 1-bit flags, since each format has 4 possible statuses (not-legal / legal +/// / banned / restricted), not just a binary flag. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct Legalities(pub i64); + +impl Legalities { + /// Build from Scryfall's `legalities` object (format-slug -> status-string + /// map). Any format not present in `map`, or present with an unrecognized + /// status string, decodes as `NotLegal`. + pub fn from_scryfall(map: &std::collections::HashMap) -> Self { + let mut bits = 0i64; + for (i, (key, _label)) in LEGALITY_FORMATS.iter().enumerate() { + let status: i64 = match map.get(*key).map(String::as_str) { + Some("legal") => 1, + Some("banned") => 2, + Some("restricted") => 3, + _ => 0, // "not_legal", or key missing entirely + }; + bits |= status << (i * 2); + } + Legalities(bits) + } + + /// Decode the status for a given format's display label (e.g. + /// "Commander", "Modern" — matches DeckView's `FORMATS` strings). + /// Unknown labels decode as `NotLegal`. + pub fn status_for(self, format_label: &str) -> LegalStatus { + let Some(i) = LEGALITY_FORMATS.iter().position(|(_, label)| *label == format_label) else { + return LegalStatus::NotLegal; + }; + match (self.0 >> (i * 2)) & 0b11 { + 1 => LegalStatus::Legal, + 2 => LegalStatus::Banned, + 3 => LegalStatus::Restricted, + _ => LegalStatus::NotLegal, + } + } +} + /// A card as returned by search / library queries. Oracle-level (one per /// `oracle_id`), enriched with the caller's ownership + deck context where relevant. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -102,6 +169,10 @@ pub struct CardRef { /// USD market price (Scryfall's `prices.usd`) for the same printing that /// supplied `image_uri`, or 0 if unknown. pub price_usd: f64, + /// Per-format legality (banned/restricted/legal). See + /// [`LEGALITY_FORMATS`] for the bit-position order and + /// [`Legalities::status_for`] to decode a specific format. + pub legalities: Legalities, } /// A library row: a card the user owns, with quantity and its deck memberships. @@ -212,3 +283,38 @@ pub struct LibraryFilter { /// Case-insensitive substring match against the card's oracle rules text. pub oracle_text: Option, } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn legalities_round_trip_all_statuses() { + let mut map = HashMap::new(); + map.insert("standard".to_string(), "legal".to_string()); + map.insert("modern".to_string(), "banned".to_string()); + map.insert("vintage".to_string(), "restricted".to_string()); + // "pauper" deliberately absent + + let legalities = Legalities::from_scryfall(&map); + + assert_eq!(legalities.status_for("Standard"), LegalStatus::Legal); + assert_eq!(legalities.status_for("Modern"), LegalStatus::Banned); + assert_eq!(legalities.status_for("Vintage"), LegalStatus::Restricted); + assert_eq!(legalities.status_for("Pauper"), LegalStatus::NotLegal); + } + + #[test] + fn legalities_unknown_format_label_is_not_legal() { + let legalities = Legalities::from_scryfall(&HashMap::new()); + assert_eq!(legalities.status_for("Nonsense"), LegalStatus::NotLegal); + } + + #[test] + fn legalities_default_is_all_not_legal() { + let legalities = Legalities::default(); + assert_eq!(legalities.status_for("Commander"), LegalStatus::NotLegal); + assert_eq!(legalities.status_for("Brawl"), LegalStatus::NotLegal); + } +} diff --git a/crates/mtgc-core/src/search.rs b/crates/mtgc-core/src/search.rs index 5caaca5..c58d25f 100644 --- a/crates/mtgc-core/src/search.rs +++ b/crates/mtgc-core/src/search.rs @@ -10,7 +10,7 @@ use rusqlite::Connection; use crate::db::REF_DB; use crate::error::Result; -use crate::model::{CardRef, ColorSet}; +use crate::model::{CardRef, ColorSet, Legalities}; /// One searchable entry, carrying the full [`CardRef`] so a hit needs no extra /// DB round-trip. `AsRef` yields the name that nucleo matches against. @@ -34,7 +34,8 @@ impl NameIndex { pub fn build(conn: &Connection) -> Result { let mut stmt = conn.prepare(&format!( "SELECT oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity, - oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless, {price} + oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless, + oc.legalities, {price} FROM {REF_DB}.oracle_cards oc", image = crate::db::image_subquery("image_uri"), back = crate::db::image_subquery("back_image_uri"), @@ -54,7 +55,8 @@ impl NameIndex { back_image_uri: r.get::<_, Option>(7)?.unwrap_or_default(), produced_mana: ColorSet(r.get::<_, i64>(8)? as u8), produces_colorless: r.get::<_, i64>(9)? != 0, - price_usd: r.get::<_, Option>(10)?.unwrap_or_default(), + legalities: Legalities(r.get::<_, i64>(10)?), + price_usd: r.get::<_, Option>(11)?.unwrap_or_default(), }, }) })? diff --git a/crates/mtgc-core/src/sql/reference_schema.sql b/crates/mtgc-core/src/sql/reference_schema.sql index 2118fa4..e68a89f 100644 --- a/crates/mtgc-core/src/sql/reference_schema.sql +++ b/crates/mtgc-core/src/sql/reference_schema.sql @@ -12,7 +12,8 @@ CREATE TABLE IF NOT EXISTS oracle_cards ( type_line TEXT NOT NULL DEFAULT '', oracle_text TEXT NOT NULL DEFAULT '', produced_mana INTEGER NOT NULL DEFAULT 0, -- WUBRG bitmask (lands/rocks/dorks) - produces_colorless INTEGER NOT NULL DEFAULT 0 -- boolean: can produce {C} + produces_colorless INTEGER NOT NULL DEFAULT 0, -- boolean: can produce {C} + legalities INTEGER NOT NULL DEFAULT 0 -- 2 bits/format x 8 formats, see model::LEGALITY_FORMATS ); CREATE INDEX IF NOT EXISTS idx_oracle_name_lower ON oracle_cards(name_lower); CREATE INDEX IF NOT EXISTS idx_oracle_ci ON oracle_cards(color_identity); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 17e53e1..39a8511 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -220,3 +220,48 @@ pub fn card_decks(db: Db, oracle_id: String) -> Res> { let s = db.lock().unwrap(); s.catalog()?.card_decks(&oracle_id).map_err(|e| e.to_string()) } + +#[derive(Serialize)] +pub struct DeckImportResult { + pub deck: Deck, + pub unresolved: Vec, +} + +#[derive(Serialize)] +pub struct LibraryImportResult { + pub added: Vec<(String, i64)>, + pub unresolved: Vec, +} + +#[tauri::command] +pub fn export_deck(db: Db, deck_id: i64, path: String) -> Res<()> { + let s = db.lock().unwrap(); + let text = s.catalog()?.export_deck_text(deck_id).map_err(|e| e.to_string())?; + std::fs::write(&path, text).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn import_deck(db: Db, path: String, name: String) -> Res { + let s = db.lock().unwrap(); + let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; + let (deck, unresolved) = s.catalog()?.import_deck(&name, &text).map_err(|e| e.to_string())?; + Ok(DeckImportResult { deck, unresolved }) +} + +#[tauri::command] +pub fn export_library_cards(db: Db, oracle_ids: Vec, path: String) -> Res<()> { + let s = db.lock().unwrap(); + let text = s + .catalog()? + .export_library_text(&oracle_ids) + .map_err(|e| e.to_string())?; + std::fs::write(&path, text).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn import_library_cards(db: Db, path: String) -> Res { + let s = db.lock().unwrap(); + let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; + let (added, unresolved) = s.catalog()?.import_library_list(&text).map_err(|e| e.to_string())?; + Ok(LibraryImportResult { added, unresolved }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ba77e81..a15da86 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -42,6 +42,10 @@ pub fn run() { commands::set_deck_card, commands::deck_contents, commands::card_decks, + commands::export_deck, + commands::import_deck, + commands::export_library_cards, + commands::import_library_cards, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.tsx b/src/App.tsx index 6a1b082..26a7437 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { StatTile, usePersistedState, } from "./components"; +import { CommandPalette } from "./CommandPalette"; import { CardEntry } from "./views/CardEntry"; import { LibraryBrowser } from "./views/LibraryBrowser"; import { DeckView } from "./views/DeckView"; @@ -399,6 +400,12 @@ export default function App() { + ); } diff --git a/src/CommandPalette.tsx b/src/CommandPalette.tsx new file mode 100644 index 0000000..c128f34 --- /dev/null +++ b/src/CommandPalette.tsx @@ -0,0 +1,170 @@ +import { useEffect, useRef, useState } from "react"; +import { api, CardRef, Deck } from "./api"; +import { DialogFrame, useClickPreview } from "./components"; + +type Tab = "add" | "library" | "decks"; + +interface PaletteItem { + key: string; + label: string; + hint?: string; + run: () => void; +} + +/** Simple, dependency-free case-insensitive subsequence match — e.g. "gtd" + * matches "Go to Decks". Good enough for the small static command list and + * the deck list; card search still defers to the server-side fuzzy matcher + * via `api.searchCards`. */ +function subsequenceMatch(query: string, text: string): boolean { + if (!query) return true; + const q = query.toLowerCase(); + const t = text.toLowerCase(); + let qi = 0; + for (let ti = 0; ti < t.length && qi < q.length; ti++) { + if (t[ti] === q[qi]) qi++; + } + return qi === q.length; +} + +/** + * Alt+/ opens a fuzzy-searchable palette of navigation commands, decks, and + * cards. Selecting a deck switches to the Decks tab with it pre-selected; + * selecting a card opens the existing click-to-zoom preview modal without + * changing tabs. + */ +export function CommandPalette({ + onNavigate, + onUpdateCardData, + onOpenLibrary, + onNewLibrary, +}: { + onNavigate: (tab: Tab) => void; + onUpdateCardData: () => void; + onOpenLibrary: () => void; + onNewLibrary: () => void; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [decks, setDecks] = useState([]); + const [cardResults, setCardResults] = useState([]); + const [sel, setSel] = useState(0); + const inputRef = useRef(null); + const { open: openCardPreview } = useClickPreview(); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.altKey && e.key === "/") { + e.preventDefault(); + setOpen((o) => !o); + } + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + useEffect(() => { + if (!open) return; + setQuery(""); + setCardResults([]); + api.listDecks().then(setDecks); + setTimeout(() => inputRef.current?.focus(), 0); + }, [open]); + + useEffect(() => { + if (!open) return; + const q = query.trim(); + if (!q) return setCardResults([]); + const t = setTimeout(() => api.searchCards(q, 8).then(setCardResults), 80); + return () => clearTimeout(t); + }, [query, open]); + + function close() { + setOpen(false); + } + + const staticCommands: PaletteItem[] = [ + { key: "nav-add", label: "Go to Add Cards", run: () => onNavigate("add") }, + { key: "nav-library", label: "Go to Library", run: () => onNavigate("library") }, + { key: "nav-decks", label: "Go to Decks", run: () => onNavigate("decks") }, + { key: "update-card-data", label: "Update card data", run: onUpdateCardData }, + { key: "open-library", label: "Switch library file…", run: onOpenLibrary }, + { key: "new-library", label: "Create new library file…", run: onNewLibrary }, + ]; + + const items: PaletteItem[] = [ + ...staticCommands.filter((c) => subsequenceMatch(query, c.label)), + ...decks + .filter((d) => subsequenceMatch(query, d.name)) + .map((d) => ({ + key: `deck-${d.id}`, + label: d.name, + hint: "Deck", + run: () => { + // DeckView reads its initial selection from this same + // localStorage key on mount (usePersistedState("deck-selected"), + // src/views/DeckView.tsx). App.tsx unmounts/remounts DeckView on + // tab switch, so writing here before navigating is enough for it + // to pick up the selection on its next mount. + localStorage.setItem("deck-selected", JSON.stringify(d.id)); + onNavigate("decks"); + }, + })), + ...cardResults.map((c) => ({ + key: `card-${c.oracle_id}`, + label: c.name, + hint: c.type_line, + run: () => openCardPreview(c), + })), + ].slice(0, 20); + + useEffect(() => setSel(0), [items.length, query]); + + function pick(item: PaletteItem) { + item.run(); + close(); + } + + function onKeyDown(e: React.KeyboardEvent) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSel((s) => Math.min(s + 1, items.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSel((s) => Math.max(s - 1, 0)); + } else if (e.key === "Enter" && items[sel]) { + e.preventDefault(); + pick(items[sel]); + } + } + + if (!open) return null; + + return ( + + setQuery(e.target.value)} + onKeyDown={onKeyDown} + /> + {items.length > 0 && ( +
    + {items.map((item, i) => ( +
  • setSel(i)} + onClick={() => pick(item)} + > + {item.label} + {item.hint && {item.hint}} +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/api.ts b/src/api.ts index 3116e51..c87b0fa 100644 --- a/src/api.ts +++ b/src/api.ts @@ -15,6 +15,7 @@ export interface CardRef { produced_mana: number; // WUBRG bitmask of mana this card can tap for produces_colorless: boolean; // can produce {C} price_usd: number; // USD market price, or 0 if unknown + legalities: number; // 2 bits/format bitmask, see LEGALITY_FORMATS } export interface LibraryCard extends CardRef { @@ -41,6 +42,16 @@ export interface Deck { notes: string; } +export interface DeckImportResult { + deck: Deck; + unresolved: string[]; +} + +export interface LibraryImportResult { + added: [string, number][]; + unresolved: string[]; +} + export interface LibraryFilter { text?: string | null; colors_exact?: number | null; @@ -107,6 +118,32 @@ export function maskToLetters(mask: number): string { .join(""); } +// --- Legality bitmask helpers ---------------------------------------------- +// Mirrors mtgc-core's model::LEGALITY_FORMATS / Legalities exactly: 2 bits per +// format, in this fixed order. Do not reorder. + +export const LEGALITY_FORMATS: { key: string; label: string }[] = [ + { key: "standard", label: "Standard" }, + { key: "pioneer", label: "Pioneer" }, + { key: "modern", label: "Modern" }, + { key: "legacy", label: "Legacy" }, + { key: "vintage", label: "Vintage" }, + { key: "pauper", label: "Pauper" }, + { key: "commander", label: "Commander" }, + { key: "brawl", label: "Brawl" }, +]; + +export type LegalStatus = "not_legal" | "legal" | "banned" | "restricted"; + +const LEGAL_STATUS_CODES: readonly LegalStatus[] = ["not_legal", "legal", "banned", "restricted"]; + +export function legalStatusFor(legalities: number, formatLabel: string): LegalStatus { + const i = LEGALITY_FORMATS.findIndex((f) => f.label === formatLabel); + if (i < 0) return "not_legal"; + const code = (legalities >> (i * 2)) & 0b11; + return LEGAL_STATUS_CODES[code]; +} + // --- Commands ------------------------------------------------------------- export const api = { @@ -150,4 +187,12 @@ export const api = { ) => invoke("set_deck_card", { deckId, oracleId, quantity, isCommander, isMaybeboard }), deckContents: (deckId: number) => invoke("deck_contents", { deckId }), cardDecks: (oracleId: string) => invoke("card_decks", { oracleId }), + + exportDeck: (deckId: number, path: string) => invoke("export_deck", { deckId, path }), + importDeck: (path: string, name: string) => + invoke("import_deck", { path, name }), + exportLibraryCards: (oracleIds: string[], path: string) => + invoke("export_library_cards", { oracleIds, path }), + importLibraryCards: (path: string) => + invoke("import_library_cards", { path }), }; diff --git a/src/components.tsx b/src/components.tsx index 386b62f..3d540c8 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -63,6 +63,26 @@ const ICON_ARROW_LEFT_CIRCLE = icon( , ); +const ICON_CARDS = icon( + <> + + + , +); +const ICON_DOWNLOAD = icon( + <> + + + + , +); +const ICON_UPLOAD = icon( + <> + + + + , +); /** * A small round icon-only button, used consistently for lightweight actions @@ -108,22 +128,29 @@ export const Icons = { close: ICON_CLOSE, helpCircle: ICON_HELP_CIRCLE, arrowLeftCircle: ICON_ARROW_LEFT_CIRCLE, + cards: ICON_CARDS, + download: ICON_DOWNLOAD, + upload: ICON_UPLOAD, }; // --- Dialogs ------------------------------------------------------------ /** - * A centered modal dialog (backdrop + card) shared by [`ConfirmDialog`] and - * [`PromptDialog`]. Dismisses on Escape or backdrop click. + * A centered modal dialog (backdrop + card) shared by [`ConfirmDialog`], + * [`PromptDialog`], and other dialogs built outside this module (e.g. the + * command palette). Dismisses on Escape or backdrop click. */ -function DialogFrame({ +export function DialogFrame({ title, children, onCancel, + wide, }: { title: string; children: ReactNode; onCancel: () => void; + /** Wider variant for content that needs more room than a typical confirm/prompt box. */ + wide?: boolean; }) { useEffect(() => { const onKey = (e: KeyboardEvent) => e.key === "Escape" && onCancel(); @@ -133,7 +160,7 @@ function DialogFrame({ return (
-
e.stopPropagation()}> +
e.stopPropagation()}>

{title}

{children}
@@ -220,6 +247,83 @@ export function PromptDialog({ ); } +/** Read-only informational dialog: a message plus an optional bulleted list + * (e.g. unresolved card names after an import). Single "OK" dismiss button. */ +export function InfoDialog({ + title, + message, + items, + onClose, +}: { + title: string; + message?: ReactNode; + items?: string[]; + onClose: () => void; +}) { + return ( + + {message &&

{message}

} + {items && items.length > 0 && ( +
    + {items.map((it, i) => ( +
  • {it}
  • + ))} +
+ )} +
+ +
+
+ ); +} + +/** Modal shuffle-and-draw sample hand: shows the drawn cards and lets the + * caller draw more or reshuffle, on top of a click/Escape-to-close backdrop. */ +export function SampleHandModal({ + hand, + libraryCount, + onDrawOne, + onNewHand, + onClose, +}: { + hand: CardRef[]; + libraryCount: number; + onDrawOne: () => void; + onNewHand: () => void; + onClose: () => void; +}) { + return ( + +
+ + {libraryCount} card{libraryCount === 1 ? "" : "s"} left in library + + + + +
+ {hand.length > 0 ? ( +
+ {hand.map((c, i) => ( + + ))} +
+ ) : ( +

Your deck has no cards to draw yet.

+ )} +
+ +
+
+ ); +} + /** * Inline editor for a card's free-text storage location (e.g. "Box 1"). * Saves on blur; used for owned cards that aren't in any deck. @@ -719,10 +823,12 @@ function primaryType(typeLine: string): string { return words[words.length - 1] || "Other"; } -function firstSubtype(typeLine: string): string { +/** e.g. "Creature — Chimera" — prefixed with the primary type so cards group + * by (type, subtype) rather than colliding on a subtype shared across types. */ +function subtypeLabel(typeLine: string): string { const parts = typeLine.split("—"); - if (parts.length < 2) return "No subtype"; - return parts[1].trim().split(/\s+/)[0] || "No subtype"; + const subtype = parts.length < 2 ? "No subtype" : parts[1].trim().split(/\s+/)[0] || "No subtype"; + return `${primaryType(typeLine)} — ${subtype}`; } function colorLabel(mask: number): string { @@ -741,7 +847,7 @@ export function groupCards( mode === "type" ? primaryType(c.type_line) : mode === "subtype" - ? firstSubtype(c.type_line) + ? subtypeLabel(c.type_line) : mode === "cmc" ? `Mana value ${c.mana_value}` : colorLabel(c.color_identity); @@ -962,6 +1068,7 @@ export function CardTile({ card, corner, cornerLeft, + cornerBottomLeft, footer, selected, }: { @@ -969,6 +1076,8 @@ export function CardTile({ corner?: ReactNode; /** Rendered top-left, e.g. a selection checkbox (see `selected`). */ cornerLeft?: ReactNode; + /** Rendered bottom-left, e.g. a secondary per-card action button. */ + cornerBottomLeft?: ReactNode; footer?: ReactNode; /** Highlights the tile and keeps `cornerLeft` visible without hovering. */ selected?: boolean; @@ -990,6 +1099,7 @@ export function CardTile({ )} {cornerLeft &&
{cornerLeft}
} {corner &&
{corner}
} + {cornerBottomLeft &&
{cornerBottomLeft}
} {card.price_usd > 0 && (
${card.price_usd.toFixed(2)} diff --git a/src/styles.css b/src/styles.css index 17e652d..b5c58e8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -621,14 +621,14 @@ h2 { letter-spacing: -0.2px; } h3 { - margin: 18px 0 10px; - font-size: 13px; - font-weight: 700; + margin: 20px 0 12px; + font-size: 16px; + font-weight: 800; text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--faint); - border-bottom: 1px solid var(--border-soft); - padding-bottom: 8px; + letter-spacing: 0.05em; + color: var(--text-strong); + border-bottom: 2px solid var(--border-soft); + padding-bottom: 10px; } h4 { margin: 14px 0 7px; @@ -636,6 +636,26 @@ h4 { font-weight: 600; color: var(--accent-hover); } + +/* "Group by" sub-headers (e.g. per card type/subtype/CMC/color within a + * section) — sticky within .content's scroll area so the current group + * stays visible while scrolling through a long list, instead of scrolling + * out of view along with its cards. Applied via class (not the bare h3/h4 + * tag) since h3 is also used for .dialog-title, which must not be sticky. */ +.group-header { + position: sticky; + top: 0; + z-index: 5; + margin: 14px 0 8px; + padding: 8px 0; + background: var(--bg-app); + font-size: 15px; + font-weight: 700; + text-transform: none; + letter-spacing: normal; + color: var(--text-strong); + border-bottom: 2px solid var(--accent); +} .muted { color: var(--muted); } @@ -660,6 +680,86 @@ h4 { font-weight: 700; } +/* Deck format-legality panel: a prominent, colorful tile, matching + .commander-hero / .deck-stats in weight, above the fold on every deck. */ +.legality-tile { + display: flex; + flex-direction: column; + gap: 10px; + border: 1px solid transparent; + border-radius: var(--radius-lg); + padding: 14px 16px; + margin-bottom: 16px; +} +.legality-tile.legal { + background: rgba(75, 176, 115, 0.16); + border-color: rgba(75, 176, 115, 0.4); +} +.legality-tile.illegal { + background: rgba(216, 80, 63, 0.16); + border-color: rgba(216, 80, 63, 0.4); +} +.legality-tile.unchecked { + background: var(--bg-panel); + border-color: var(--border-soft); +} +.legality-tile-head { + display: flex; + align-items: center; + gap: 14px; +} +.legality-tile-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: 999px; + font-size: 20px; + font-weight: 700; +} +.legality-tile.legal .legality-tile-icon { + background: var(--success); + color: #fff; +} +.legality-tile.illegal .legality-tile-icon { + background: var(--danger); + color: #fff; +} +.legality-tile.unchecked .legality-tile-icon { + background: var(--muted); + color: var(--bg-panel); +} +.legality-tile-info { + display: flex; + flex-direction: column; + gap: 2px; +} +.legality-tile-status { + font-weight: 700; + font-size: 14px; +} +.legality-tile.legal .legality-tile-status { + color: var(--success); +} +.legality-tile.illegal .legality-tile-status { + color: var(--danger); +} +.legality-tile-detail { + font-size: 12px; + color: var(--muted); +} +.legality-tile-reasons { + margin: 0 0 0 54px; + padding-left: 18px; + display: flex; + flex-direction: column; + gap: 4px; + font-size: 13px; + color: var(--text); +} + /* ============================================================ Quantity stepper: [− N +] for owned-copy counts. Used on Add Cards and both Library views (list + tile) for a consistent look. @@ -844,6 +944,11 @@ li:focus-within .row-actions { padding: 20px; box-shadow: var(--shadow-lg); } +.dialog-wide { + width: min(900px, 92vw); + max-height: 86vh; + overflow-y: auto; +} .dialog-title { margin: 0 0 10px; font-size: 16px; @@ -860,6 +965,18 @@ li:focus-within .row-actions { font-size: 14px; line-height: 1.5; } +.dialog-list { + margin: 0 0 18px; + padding-left: 20px; + display: flex; + flex-direction: column; + gap: 6px; + max-height: 320px; + overflow-y: auto; + color: var(--muted); + font-size: 14px; + line-height: 1.5; +} .dialog-field { display: flex; flex-direction: column; @@ -1210,7 +1327,10 @@ tbody tr:hover { .preview-modal-backdrop { position: fixed; inset: 0; - z-index: 300; + /* Above .dialog-backdrop (400) — a card can now be clicked to zoom from + inside a dialog (e.g. the sample hand modal), and the zoomed preview + must stay on top of the dialog it was opened from. */ + z-index: 450; display: flex; align-items: center; justify-content: center; @@ -1370,6 +1490,17 @@ tbody tr:hover { .tile:focus-within .tile-corner-bottom-right { opacity: 1; } +.tile-corner-bottom-left { + position: absolute; + bottom: 8px; + left: 8px; + opacity: 0; + transition: opacity 0.12s; +} +.tile:hover .tile-corner-bottom-left, +.tile:focus-within .tile-corner-bottom-left { + opacity: 1; +} .tile-selected .tile-img { box-shadow: 0 0 0 3px var(--accent), var(--shadow-md); } @@ -1450,6 +1581,14 @@ tbody tr:hover { border: 1px solid var(--border-soft); border-radius: var(--radius-lg); padding: 14px 16px; + margin-bottom: 16px; +} +/* Separates the deck-level info panels (commander, format, analysis, + legality) from the card-management controls + lists below. */ +.deck-section-divider { + border: none; + border-top: 1px solid var(--border-soft); + margin: 4px 0 18px; } .commander-box { display: flex; @@ -1558,3 +1697,4 @@ tbody tr:hover { color: var(--faint); margin-bottom: 2px; } + diff --git a/src/views/DeckView.tsx b/src/views/DeckView.tsx index 8b702fb..e175e7b 100644 --- a/src/views/DeckView.tsx +++ b/src/views/DeckView.tsx @@ -1,5 +1,6 @@ +import { open, save } from "@tauri-apps/plugin-dialog"; import { useEffect, useMemo, useRef, useState, useCallback } from "react"; -import { api, CardRef, COLORS, Deck, DeckEntry } from "../api"; +import { api, CardRef, COLORS, Deck, DeckEntry, DeckImportResult, legalStatusFor } from "../api"; import { CardSearchBox, CardThumb, @@ -11,9 +12,11 @@ import { GroupMode, IconButton, Icons, + InfoDialog, ManaCurve, PromptDialog, QtyStepper, + SampleHandModal, StatTile, ViewToggle, groupCards, @@ -34,6 +37,8 @@ type DeckDialog = | { kind: "delete"; deck: Deck } | { kind: "rename"; deck: Deck } | { kind: "duplicate"; deck: Deck } + | { kind: "import-name"; suggestedName: string; path: string } + | { kind: "import-result"; result: DeckImportResult } | null; function friendlyDeckError(err: unknown): string { @@ -181,6 +186,8 @@ function CommanderHero({ card, onClear }: { card: DeckEntry; onClear: () => void // the Library tab, so a mistaken removal can be undone with +. const REMOVAL_GRACE_MS = 5000; +const STARTING_HAND_SIZE = 7; + const FORMATS = [ "", "Commander", @@ -195,10 +202,131 @@ const FORMATS = [ ]; const COMMANDER_FORMATS = ["Commander", "Brawl"]; +interface DeckLegality { + status: "legal" | "illegal" | "unchecked"; + reasons: string[]; +} + +const COMMANDER_DECK_SIZE = 100; +const CONSTRUCTED_MIN_DECK_SIZE = 60; +const CONSTRUCTED_MAX_COPIES = 4; + /** - * Deck editor. A deck's cards are split into two groups — "In your library" - * (owned) and "Not owned" — each with its own search box, and each groupable by - * card attribute. Deck-level format + commander metadata sit at the top. + * Format-legality check for the deck as a whole: structural deck-building + * rules (size, singleton/copy limits, commander color identity) plus + * Scryfall's per-format banned/restricted lists (via each card's + * `legalities` bitmask). Blank format and "Other" run no checks at all — + * "unchecked" is a neutral state, distinct from "illegal". + */ +function computeDeckLegality(deck: Deck | null | undefined, contents: DeckEntry[]): DeckLegality { + if (!deck || deck.format === "" || deck.format === "Other") { + return { status: "unchecked", reasons: [] }; + } + + const reasons: string[] = []; + const cards = contents.filter((e) => !e.is_maybeboard); + const isBasicLand = (e: DeckEntry) => e.type_line.includes("Basic Land"); + + // NOTE: a handful of real cards (Relentless Rats, Persistent Petitioners, + // Shadowborn Apostle, Dragon's Approach, Seven Dwarves, etc.) override the + // singleton/copy-limit rule via oracle text ("a deck can have any number + // of cards named..."). Detecting this needs oracle_text on CardRef, which + // isn't wired through yet — known limitation, not blocking this feature. + + if (COMMANDER_FORMATS.includes(deck.format)) { + // Brawl (Scryfall's `brawl` legality key = paper/Historic Brawl) is + // structurally identical to Commander — 100 cards incl. commander, + // singleton, commander color-identity restriction — differing only in + // which legal/banned card pool applies. Both formats share this + // rule-set and differ only in which legalities bit (deck.format itself, + // "Commander" or "Brawl") is consulted for the banned/restricted check + // below. + const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0); + if (totalCards !== COMMANDER_DECK_SIZE) { + reasons.push( + `Deck has ${totalCards} cards; ${deck.format} decks need exactly ${COMMANDER_DECK_SIZE} (including the commander).`, + ); + } + + const commanders = cards.filter((e) => e.is_commander); + if (commanders.length === 0) { + reasons.push(`No commander is set.`); + } else if (commanders.length > 1) { + reasons.push(`More than one card is marked as commander.`); + } + const commander = commanders[0]; + + for (const e of cards) { + if (e.is_commander) continue; + if (!isBasicLand(e) && e.quantity > 1) { + reasons.push( + `${e.name} has ${e.quantity} copies; ${deck.format} decks allow only 1 (except basic lands).`, + ); + } + } + + if (commander) { + for (const e of cards) { + if (e.is_commander) continue; + const outsideIdentity = e.color_identity & ~commander.color_identity; + if (outsideIdentity !== 0) { + reasons.push(`${e.name}'s color identity is outside the commander's color identity.`); + } + } + } + + for (const e of cards) { + const status = legalStatusFor(e.legalities, deck.format); + if (status === "banned") { + reasons.push(`${e.name} is banned in ${deck.format}.`); + } else if (status === "restricted" && e.quantity > 1) { + reasons.push( + `${e.name} is restricted in ${deck.format} (max 1 copy), deck has ${e.quantity}.`, + ); + } + } + } else { + // 60-card constructed formats: Standard, Pioneer, Modern, Legacy, + // Vintage, Pauper. No commander concept — is_commander is ignored here. + const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0); + if (totalCards < CONSTRUCTED_MIN_DECK_SIZE) { + reasons.push( + `Deck has ${totalCards} cards; ${deck.format} decks need at least ${CONSTRUCTED_MIN_DECK_SIZE}.`, + ); + } + + for (const e of cards) { + if (!isBasicLand(e) && e.quantity > CONSTRUCTED_MAX_COPIES) { + reasons.push( + `${e.name} has ${e.quantity} copies; ${deck.format} allows at most ${CONSTRUCTED_MAX_COPIES} (except basic lands).`, + ); + } + } + + for (const e of cards) { + const status = legalStatusFor(e.legalities, deck.format); + if (status === "banned") { + reasons.push(`${e.name} is banned in ${deck.format}.`); + } else if (status === "restricted" && e.quantity > 1) { + // In practice this only ever fires for Vintage among the 8 tracked + // formats, since Vintage is the only format with a restricted list — + // but the check is generic/future-proof rather than Vintage-special-cased. + reasons.push( + `${e.name} is restricted in ${deck.format} (max 1 copy), deck has ${e.quantity}.`, + ); + } + } + } + + return { status: reasons.length ? "illegal" : "legal", reasons }; +} + +/** + * Deck editor. A deck's mainboard cards are split into two groups — "In your + * library" (owned) and "Not owned" — each with its own search box and each + * groupable by card attribute; "Combine sections" merges these two into one + * list. The maybeboard is always shown as its own separate section. Deck-level + * format + commander metadata sit at the top. */ export function DeckView() { const [decks, setDecks] = useState([]); @@ -207,6 +335,7 @@ export function DeckView() { const [contents, setContents] = useState([]); const [view, setView] = useViewMode("deck-view"); const [groupBy, setGroupBy] = usePersistedState("deck-group", "none"); + const [combineSections, setCombineSections] = usePersistedState("deck-combine-sections", false); const [dialog, setDialog] = useState(null); const [dialogError, setDialogError] = useState(null); @@ -225,6 +354,10 @@ export function DeckView() { const [bulkBusy, setBulkBusy] = useState(false); const selectAllRef = useRef(null); + const [handOpen, setHandOpen] = useState(false); + const [hand, setHand] = useState([]); + const [libraryStack, setLibraryStack] = useState([]); + const deck = decks.find((d) => d.id === selected) ?? null; const loadDecks = useCallback(async () => { @@ -251,6 +384,9 @@ export function DeckView() { useEffect(() => { loadContents(); setSelectedCards(new Set()); + setHandOpen(false); + setHand([]); + setLibraryStack([]); }, [loadContents]); // Cancel any in-flight removal timers on unmount so they don't fire against @@ -310,6 +446,39 @@ export function DeckView() { setSelected(d.id); } + async function exportDeck(d: Deck) { + const path = await save({ + title: "Export deck", + defaultPath: `${d.name}.txt`, + filters: [{ name: "Decklist", extensions: ["txt"] }], + }); + if (typeof path === "string") await api.exportDeck(d.id, path); + } + + async function pickImportFile() { + const path = await open({ + title: "Import deck", + filters: [{ name: "Decklist", extensions: ["txt"] }], + }); + if (typeof path !== "string") return; + const base = path.split(/[/\\]/).pop() ?? "Imported deck"; + const suggestedName = base.replace(/\.txt$/i, ""); + setDialog({ kind: "import-name", suggestedName, path }); + } + + async function confirmImportName(name: string) { + if (dialog?.kind !== "import-name") return; + try { + const result = await api.importDeck(dialog.path, name); + setDialog({ kind: "import-result", result }); + setDialogError(null); + await loadDecks(); + setSelected(result.deck.id); + } catch (err) { + setDialogError(friendlyDeckError(err)); + } + } + async function addToDeck(card: CardRef) { if (selected === null) return; const existing = contents.find((e) => e.oracle_id === card.oracle_id); @@ -345,6 +514,35 @@ export function DeckView() { await loadContents(); } + /** Every mainboard copy (commander/maybeboard excluded), one entry per + * physical copy so a card at quantity N is N times as likely to be drawn, + * shuffled in place (Fisher-Yates). */ + function shuffledLibrary(): DeckEntry[] { + const pool: DeckEntry[] = []; + for (const e of contents) { + if (e.is_commander || e.is_maybeboard) continue; + for (let i = 0; i < e.quantity; i++) pool.push(e); + } + for (let i = pool.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [pool[i], pool[j]] = [pool[j], pool[i]]; + } + return pool; + } + + function drawNewHand() { + const shuffled = shuffledLibrary(); + setHand(shuffled.slice(0, STARTING_HAND_SIZE)); + setLibraryStack(shuffled.slice(STARTING_HAND_SIZE)); + setHandOpen(true); + } + + function drawOneMore() { + if (libraryStack.length === 0) return; + setHand((h) => [...h, libraryStack[0]]); + setLibraryStack((stack) => stack.slice(1)); + } + async function setQty(oracleId: string, qty: number) { if (selected === null) return; const clamped = Math.max(0, qty); @@ -478,6 +676,7 @@ export function DeckView() { const maybeboard = contents.filter((e) => e.is_maybeboard); const commanderEntry = contents.find((e) => e.oracle_id === deck?.commander_oracle_id); const deckStats = useMemo(() => computeDeckStats(contents), [contents]); + const deckLegality = useMemo(() => computeDeckLegality(deck, contents), [deck, contents]); function deckTiles(entries: DeckEntry[]) { return ( @@ -506,13 +705,10 @@ export function DeckView() { pending={pendingRemoval.has(e.oracle_id)} /> } - footer={ - <> - {e.is_commander && ★ commander} - - setMaybeboard(e, !e.is_maybeboard)} /> - + cornerBottomLeft={ + setMaybeboard(e, !e.is_maybeboard)} /> } + footer={e.is_commander ? ★ commander : undefined} /> ))}
@@ -580,7 +776,7 @@ export function DeckView() { return groupCards(entries, groupBy).map((g) => (
{g.label && ( -

+

{g.label} {g.cards.length}

)} @@ -592,7 +788,10 @@ export function DeckView() { return (
-

Decks

+
+

Decks

+ +
-
- -

{deck.name}

-
-
- - -
-
- - {selectedCards.size > 0 && ( -
- {selectedCards.size} selected -
- -
- - +
- )} +
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && ( )} + {/* Deck metadata: format + (for Commander/Brawl) the commander card. */} +
+ + + {COMMANDER_FORMATS.includes(deck.format) && ( +
+ {!commanderEntry && No commander set yet.} + +
+ )} +
+ {contents.length > 0 && (
Deck analysis
@@ -737,64 +947,131 @@ export function DeckView() {
)} - {/* Deck metadata: format + (for Commander/Brawl) the commander card. */} -
- - - {COMMANDER_FORMATS.includes(deck.format) && ( -
- {!commanderEntry && No commander set yet.} - + {deck.format !== "Other" && ( +
+
+ + {deckLegality.status === "legal" ? "✓" : deckLegality.status === "illegal" ? "✗" : "—"} + +
+ + {deckLegality.status === "legal" + ? "Legal" + : deckLegality.status === "illegal" + ? "Illegal" + : "Not checked"} + + + {deckLegality.status === "legal" + ? `Meets all ${deck.format} deck-building rules.` + : deckLegality.status === "illegal" + ? `${deckLegality.reasons.length} issue${deckLegality.reasons.length === 1 ? "" : "s"} found:` + : deck.format + ? `Legality isn't checked for "${deck.format}".` + : "Set a format to check legality."} + +
- )} + {deckLegality.status === "illegal" && ( +
    + {deckLegality.reasons.map((r, i) => ( +
  • {r}
  • + ))} +
+ )} +
+ )} + +
+ +
+ + + + +
-
-

- In your library {owned.length} -

- - {cardsGroup(owned)} -
+ {selectedCards.size > 0 && ( +
+ {selectedCards.size} selected +
+ +
+ + +
+ )} -
-

- Not owned {unowned.length} -

- - {cardsGroup(unowned)} -
+ {combineSections ? ( +
+

+ All cards {owned.length + unowned.length} +

+ + {cardsGroup([...owned, ...unowned])} +
+ ) : ( + <> +
+

+ In your library {owned.length} +

+ + {cardsGroup(owned)} +
+ +
+

+ Not owned {unowned.length} +

+ + {cardsGroup(unowned)} +
+ + )}

@@ -848,6 +1125,38 @@ export function DeckView() { onCancel={() => setDialog(null)} /> )} + {dialog?.kind === "import-name" && ( + setDialog(null)} + /> + )} + {dialog?.kind === "import-result" && ( + 0 + ? `Imported "${dialog.result.deck.name}". ${dialog.result.unresolved.length} card name${dialog.result.unresolved.length === 1 ? "" : "s"} couldn't be matched:` + : `Imported "${dialog.result.deck.name}" — every card matched.` + } + items={dialog.result.unresolved} + onClose={() => setDialog(null)} + /> + )} + {handOpen && ( + setHandOpen(false)} + /> + )}

); } diff --git a/src/views/LibraryBrowser.tsx b/src/views/LibraryBrowser.tsx index 248c79a..d74781c 100644 --- a/src/views/LibraryBrowser.tsx +++ b/src/views/LibraryBrowser.tsx @@ -1,5 +1,6 @@ +import { open, save } from "@tauri-apps/plugin-dialog"; import { useEffect, useRef, useState } from "react"; -import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter } from "../api"; +import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter, LibraryImportResult } from "../api"; import { CardThumb, CardTile, @@ -7,6 +8,7 @@ import { ColorToggles, GroupBySelect, GroupMode, + InfoDialog, LocationField, PromptDialog, QtyStepper, @@ -49,9 +51,11 @@ export function LibraryBrowser() { const [selected, setSelected] = useState>(new Set()); const [bulkDeckId, setBulkDeckId] = useState(""); + const [bulkToMaybeboard, setBulkToMaybeboard] = useState(false); const [bulkDelta, setBulkDelta] = useState(1); const [locationDialogOpen, setLocationDialogOpen] = useState(false); const [bulkBusy, setBulkBusy] = useState(false); + const [importResult, setImportResult] = useState(null); const selectAllRef = useRef(null); useEffect(() => { @@ -172,7 +176,8 @@ export function LibraryBrowser() { * owned count (e.g. select a handful of basic lands, "Add All" to put * every copy you own into the deck at once) — overwrites the quantity for * cards already in the deck rather than skipping them, so it doubles as a - * bulk "fix the land count" tool. + * bulk "fix the land count" tool. `bulkToMaybeboard` sends them to the + * deck's maybeboard instead of the mainboard. */ async function bulkAddToDeck(mode: "one" | "all") { if (bulkDeckId === "") return; @@ -183,7 +188,13 @@ export function LibraryBrowser() { const targets = rows.filter((r) => selected.has(r.oracle_id)); await Promise.all( targets.map((r) => - api.setDeckCard(deck.id, r.oracle_id, mode === "all" ? r.quantity_owned : 1, false), + api.setDeckCard( + deck.id, + r.oracle_id, + mode === "all" ? r.quantity_owned : 1, + false, + bulkToMaybeboard, + ), ), ); setRefreshKey((k) => k + 1); @@ -212,6 +223,28 @@ export function LibraryBrowser() { } } + async function exportSelected() { + const targets = rows.filter((r) => selected.has(r.oracle_id)).map((r) => r.oracle_id); + if (targets.length === 0) return; + const path = await save({ + title: "Export library cards", + defaultPath: "library-export.txt", + filters: [{ name: "Decklist", extensions: ["txt"] }], + }); + if (typeof path === "string") await api.exportLibraryCards(targets, path); + } + + async function pickImportFile() { + const path = await open({ + title: "Import library cards", + filters: [{ name: "Decklist", extensions: ["txt"] }], + }); + if (typeof path !== "string") return; + const result = await api.importLibraryCards(path); + setImportResult(result); + setRefreshKey((k) => k + 1); + } + const deckOrLocation = (c: LibraryCard) => c.decks.length === 0 ? (
+
@@ -378,6 +412,17 @@ export function LibraryBrowser() { > Add All + +