Files
mtgc/crates/mtgc-core/src/manabox.rs

364 lines
14 KiB
Rust
Raw Normal View History

//! 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());
}
}