364 lines
14 KiB
Rust
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());
|
||
|
|
}
|
||
|
|
}
|