//! ManaBox collection import. ManaBox exports a collection or binder as //! either a plain-text list — the same `2 Name (SET) 172 *F*` lines //! [`crate::decklist`] already reads — or a CSV: //! ```text //! Name,Set code,Set name,Collector number,Foil,Rarity,Quantity,ManaBox ID,Scryfall ID,... //! "Mowu, Loyal Companion",WAR,War of the Spark,167,normal,uncommon,1,44237,f41a191c-...,... //! ``` //! The CSV names each row's exact printing by Scryfall ID, so that's what an //! import resolves first; the name, set and collector number are the //! fallback (a non-English printing, say, isn't in the reference db). //! `Foil` (`normal`/`foil`/`etched`) picks the copy group's finish. The other //! columns — condition, language, prices, proxy, ... — have nowhere to go and //! are ignored; proxies count as owned copies like any other row. use rusqlite::{params, OptionalExtension}; use crate::db::REF_DB; use crate::decklist::{DecklistLine, LibraryImport, LinePrinting}; use crate::error::{CoreError, Result}; use crate::Catalog; /// One row of a ManaBox CSV export, as far as the library can use it. #[derive(Debug, Clone, PartialEq)] pub struct ManaboxRow { pub name: String, pub set_code: String, pub collector_number: String, /// "nonfoil", "foil" or "etched"; `None` when the column is blank or /// holds something else. pub finish: Option, pub quantity: i64, pub scryfall_id: String, } /// The columns a file needs to be read as a ManaBox CSV. const REQUIRED: [&str; 3] = ["Name", "Quantity", "Scryfall ID"]; /// Whether `text` looks like a ManaBox CSV export: its header row names the /// columns this importer needs. pub fn is_manabox_csv(text: &str) -> bool { let first = text.trim_start_matches('\u{feff}').lines().next().unwrap_or(""); let header = split_records(first).into_iter().next().unwrap_or_default(); REQUIRED.iter().all(|col| header.iter().any(|h| h.trim() == *col)) } /// Split CSV text into records of fields (RFC 4180: quoted fields may hold /// commas, newlines and `""` escapes; CRLF and LF both end a record). Blank /// lines are dropped. fn split_records(text: &str) -> Vec> { let mut records = Vec::new(); let mut record = Vec::new(); let mut field = String::new(); let mut in_quotes = false; let mut chars = text.chars().peekable(); while let Some(c) = chars.next() { if in_quotes { match c { '"' if chars.peek() == Some(&'"') => { chars.next(); field.push('"'); } '"' => in_quotes = false, _ => field.push(c), } continue; } match c { '"' => in_quotes = true, ',' => record.push(std::mem::take(&mut field)), '\r' => {} '\n' => { record.push(std::mem::take(&mut field)); if record.iter().any(|f| !f.is_empty()) { records.push(std::mem::take(&mut record)); } else { record.clear(); } } _ => field.push(c), } } record.push(field); if record.iter().any(|f| !f.is_empty()) { records.push(record); } records } /// Parse a ManaBox CSV export. Columns are found by header name, so their /// order doesn't matter. Lenient like [`crate::decklist::parse_library_text`]: /// a row without a positive quantity or a name is skipped. Only a header /// missing a needed column is an error. pub fn parse_manabox_csv(text: &str) -> Result> { let mut records = split_records(text.trim_start_matches('\u{feff}')).into_iter(); let header = records.next().unwrap_or_default(); let col = |name: &str| header.iter().position(|h| h.trim() == name); let (Some(name), Some(quantity), Some(scryfall_id)) = (col("Name"), col("Quantity"), col("Scryfall ID")) else { return Err(CoreError::Other(format!( "not a ManaBox CSV export (needs columns {})", REQUIRED.join(", ") ))); }; let (set_code, collector_number, foil) = (col("Set code"), col("Collector number"), col("Foil")); let rows = records .filter_map(|r| { let get = |i: Option| i.and_then(|i| r.get(i)).map_or("", |f| f.trim()).to_string(); let quantity: i64 = get(Some(quantity)).parse().ok().filter(|&q| q > 0)?; let name = get(Some(name)); if name.is_empty() { return None; } let finish = match get(foil).to_lowercase().as_str() { "normal" | "nonfoil" => Some("nonfoil".to_string()), "foil" => Some("foil".to_string()), "etched" => Some("etched".to_string()), _ => None, }; Some(ManaboxRow { name, set_code: get(set_code), collector_number: get(collector_number), finish, quantity, scryfall_id: get(Some(scryfall_id)), }) }) .collect(); Ok(rows) } impl ManaboxRow { /// How an unresolved row is reported: `Name (SET) NUM`, or just the name /// when the row doesn't say which printing it is. fn label(&self) -> String { if self.set_code.is_empty() || self.collector_number.is_empty() { self.name.clone() } else { format!("{} ({}) {}", self.name, self.set_code, self.collector_number) } } } impl Catalog { /// `(oracle_id, scryfall_id)` for a ManaBox row: its Scryfall ID when the /// reference db knows it, else its name, set and number the way a /// decklist line resolves. fn resolve_manabox_row(&self, row: &ManaboxRow) -> Result)>> { if !row.scryfall_id.is_empty() { let oid: Option = self .conn .query_row( &format!("SELECT oracle_id FROM {REF_DB}.printings WHERE scryfall_id = ?1"), params![row.scryfall_id], |r| r.get(0), ) .optional()?; if let Some(oid) = oid { return Ok(Some((oid, Some(row.scryfall_id.clone())))); } } let printing = (!row.set_code.is_empty() && !row.collector_number.is_empty()).then(|| { LinePrinting { set_code: row.set_code.clone(), collector_number: row.collector_number.clone(), finish: row.finish.clone(), } }); self.resolve_line(&DecklistLine { quantity: row.quantity, name: row.name.clone(), printing }) } /// Import a ManaBox CSV export into the library, adding each row's /// copies to what's already owned and to its printing + finish copy /// group (finishes are handled as in [`Catalog::import_library_list`]). /// Returns `(name, new_total_quantity_owned)` per applied row, plus the /// rows that couldn't be matched, as `Name (SET) NUM`. See also /// [`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 |_, _| {})?; tx.commit()?; Ok(result) } pub(crate) fn import_manabox_rows( &self, rows: &[ManaboxRow], on_progress: &mut dyn FnMut(usize, usize), ) -> Result { let mut added = Vec::new(); let mut unresolved = Vec::new(); for (i, row) in rows.iter().enumerate() { on_progress(i, rows.len()); let Some((oid, printing)) = self.resolve_manabox_row(row)? else { unresolved.push(row.label()); continue; }; let new_qty = self.add_owned(&oid, row.quantity)?; if let Some(scryfall_id) = printing { self.add_imported_copies(&oid, &scryfall_id, row.finish.clone(), row.quantity)?; } added.push((row.name.clone(), new_qty)); } on_progress(rows.len(), rows.len()); Ok((added, unresolved)) } } #[cfg(test)] mod tests { use super::*; use crate::test_support; const HEADER: &str = "Name,Set code,Set name,Collector number,Foil,Rarity,Quantity,ManaBox ID,Scryfall ID,Purchase price,Misprint,Altered,Signed,Condition,Language,Proxy,Purchase price currency,Added"; fn csv(rows: &[&str]) -> String { let mut out = format!("{HEADER}\n"); for r in rows { out.push_str(r); out.push('\n'); } out } /// A ManaBox row with the columns this importer reads filled in. fn row(name: &str, set: &str, num: &str, foil: &str, qty: i64, id: &str) -> String { let name = if name.contains(',') { format!("\"{name}\"") } else { name.to_string() }; format!("{name},{set},Some Set,{num},{foil},common,{qty},1,{id},0.1,false,false,false,near_mint,en,false,USD,2024-08-25T16:15:41.417Z") } /// Sol Ring's 2X2 printing (nonfoil + foil) and Lightning Bolt's 2XM one /// (nonfoil only) and CLB one (etched only). fn add_printings(cat: &Catalog) { cat.connection() .execute_batch( "INSERT INTO ref.printings(scryfall_id, oracle_id, set_code, collector_number, finishes) VALUES ('sol-2x2', 'sol-ring', '2x2', '315', 'nonfoil,foil'), ('bolt-2xm', 'lightning-bolt', '2xm', '129', 'nonfoil'), ('bolt-clb', 'lightning-bolt', 'clb', '401', 'etched');", ) .unwrap(); } fn groups(cat: &Catalog, oid: &str) -> Vec<(String, String, i64)> { cat.list_library_copies(oid) .unwrap() .into_iter() .map(|c| (c.scryfall_id, c.finish, c.quantity)) .collect() } fn owned(cat: &Catalog, oid: &str) -> i64 { cat.list_library() .unwrap() .into_iter() .find(|c| c.card.oracle_id == oid) .map_or(0, |c| c.quantity_owned) } #[test] fn detects_manabox_csv_by_its_header() { assert!(is_manabox_csv(&csv(&[]))); assert!(is_manabox_csv(&format!("\u{feff}{HEADER}\r\n"))); assert!(!is_manabox_csv("2 Lightning Bolt (2XM) 129\n")); assert!(!is_manabox_csv("Name,Quantity\nSol Ring,1\n")); assert!(!is_manabox_csv("")); } #[test] fn parses_quoted_fields_crlf_bom_and_reordered_columns() { let text = "\u{feff}Quantity,Scryfall ID,Foil,Name,Set code,Collector number\r\n\ 2,abc,foil,\"Mowu, Loyal Companion\",WAR,167\r\n\ 0,def,normal,Sol Ring,2X2,315\r\n\ \r\n\ 1,,etched,\"Say \"\"Hi\"\"\",CLB,401\r\n"; let rows = parse_manabox_csv(text).unwrap(); assert_eq!( rows, vec![ ManaboxRow { name: "Mowu, Loyal Companion".into(), set_code: "WAR".into(), collector_number: "167".into(), finish: Some("foil".into()), quantity: 2, scryfall_id: "abc".into(), }, ManaboxRow { name: "Say \"Hi\"".into(), set_code: "CLB".into(), collector_number: "401".into(), finish: Some("etched".into()), quantity: 1, scryfall_id: String::new(), }, ] ); assert!(parse_manabox_csv("Name,Count\nSol Ring,1\n").is_err()); } #[test] fn imports_by_scryfall_id_into_copy_groups() { let fx = test_support::make(); let cat = &fx.catalog; add_printings(cat); cat.add_owned("sol-ring", 1).unwrap(); let text = csv(&[ // The name is wrong, but the Scryfall ID settles it. &row("Sol Rnig", "2X2", "315", "normal", 2, "sol-2x2"), &row("Sol Ring", "2X2", "315", "foil", 1, "sol-2x2"), &row("Lightning Bolt", "CLB", "401", "etched", 1, "bolt-clb"), &row("Lightning Bolt", "2XM", "129", "normal", 3, "bolt-2xm"), ]); let (added, unresolved) = cat.import_manabox_csv(&text).unwrap(); assert!(unresolved.is_empty(), "{unresolved:?}"); assert_eq!(added.len(), 4); assert_eq!(owned(cat, "sol-ring"), 4); assert_eq!( groups(cat, "sol-ring"), vec![ ("sol-2x2".into(), "nonfoil".into(), 2), ("sol-2x2".into(), "foil".into(), 1), ] ); assert_eq!(owned(cat, "lightning-bolt"), 4); assert_eq!( groups(cat, "lightning-bolt"), vec![ ("bolt-clb".into(), "etched".into(), 1), ("bolt-2xm".into(), "nonfoil".into(), 3), ] ); } #[test] fn falls_back_to_name_set_and_number_and_reports_the_rest() { let fx = test_support::make(); let cat = &fx.catalog; add_printings(cat); let text = csv(&[ // An ID the reference db doesn't have (a Japanese printing, say). &row("Sol Ring", "2X2", "315", "foil", 1, "sol-2x2-ja"), // Bolt's 2XM printing has no foil: owned, but left unspecified. &row("Lightning Bolt", "2XM", "129", "foil", 1, "unknown"), // A printing the reference db lacks still imports the card. &row("Serra Angel", "ZZZ", "1", "normal", 1, ""), &row("Not A Real Card", "ABC", "12", "normal", 1, "nope"), ]); let (added, unresolved) = cat.import_manabox_csv(&text).unwrap(); assert_eq!(unresolved, vec!["Not A Real Card (ABC) 12".to_string()]); assert_eq!(added.len(), 3); assert_eq!(groups(cat, "sol-ring"), vec![("sol-2x2".into(), "foil".into(), 1)]); assert_eq!(owned(cat, "lightning-bolt"), 1); assert!(groups(cat, "lightning-bolt").is_empty()); assert_eq!(owned(cat, "serra-angel"), 1); assert!(groups(cat, "serra-angel").is_empty()); } }