Files
mtgc/crates/mtgc-core/src/manabox.rs
Garrett Mills 507df81f5f ManaBox import, with progress and a format explainer
Library → Import cards… now reads a ManaBox collection export, either the
.csv or the .txt. The CSV is matched by Scryfall ID, falling back to name,
set and collector number, and its Foil column picks the copy group's finish.
Condition, language and prices are ignored; proxies count as owned.

A decklist line that names a printing now resolves to that printing's card
when several cards share the name (a card and its token, or an art-series
entry), and a bare name prefers the real card.

Imports run in one transaction (nestable via SAVEPOINT), which took the
942-row sample from 22s to 0.2s, and off the main thread, with a progress
dialog for slow ones. An explainer of the supported formats now comes
before the file picker.
2026-10-16 21:57:52 -05:00

364 lines
14 KiB
Rust

//! 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<String>,
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<Vec<String>> {
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<Vec<ManaboxRow>> {
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<usize>| 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<Option<(String, Option<String>)>> {
if !row.scryfall_id.is_empty() {
let oid: Option<String> = 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<LibraryImport> {
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<LibraryImport> {
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());
}
}