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.
This commit is contained in:
@@ -233,6 +233,12 @@ do, and import reads it back:
|
||||
printing, then a plain line for the unspecified copies. A suffix that doesn't
|
||||
match any printing still imports the card itself.
|
||||
|
||||
**Library → Import cards…** also takes a **ManaBox** export of a collection
|
||||
or binder, either the `.txt` or the `.csv`. Both add to what you already own.
|
||||
The CSV names each copy's exact printing and finish, so it's the better of
|
||||
the two. Condition, language, prices and the other ManaBox fields aren't
|
||||
kept. Proxies count as owned copies.
|
||||
|
||||
> The picker's dates and languages need card data from this version. Until you
|
||||
> update it, printings are listed without them. Whenever your card data is
|
||||
> missing something the app could use, a notice under the top bar offers
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
//! with no sections — see [`parse_library_text`]. Each tracked copy group gets
|
||||
//! its own line with a printing suffix, followed by a plain line for any
|
||||
//! unspecified copies; import adds each line's copies back into its group.
|
||||
//! ManaBox's plain-text export is this same format; its CSV export has its
|
||||
//! own importer in [`crate::manabox`].
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -48,6 +50,10 @@ use crate::error::Result;
|
||||
use crate::model::{Deck, DeckEntry};
|
||||
use crate::Catalog;
|
||||
|
||||
/// What a library import did: `(name, new_total_quantity_owned)` per
|
||||
/// applied line, plus the lines that couldn't be matched to a card.
|
||||
pub type LibraryImport = (Vec<(String, i64)>, Vec<String>);
|
||||
|
||||
/// One `<quantity> <name>` line from a decklist, prior to name resolution.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DecklistLine {
|
||||
@@ -306,12 +312,17 @@ 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.
|
||||
/// typo never silently imports the wrong card. When several cards share
|
||||
/// the name, a real card wins over a token or an art-series-style "Card".
|
||||
pub fn find_oracle_id_by_name(&self, name: &str) -> Result<Option<String>> {
|
||||
Ok(self
|
||||
.conn
|
||||
.query_row(
|
||||
&format!("SELECT oracle_id FROM {REF_DB}.oracle_cards WHERE name_lower = ?1 LIMIT 1"),
|
||||
&format!(
|
||||
"SELECT oracle_id FROM {REF_DB}.oracle_cards WHERE name_lower = ?1
|
||||
ORDER BY (type_line LIKE 'Token%' OR type_line = 'Card'), rowid
|
||||
LIMIT 1"
|
||||
),
|
||||
params![name.trim().to_lowercase()],
|
||||
|r| r.get(0),
|
||||
)
|
||||
@@ -319,9 +330,30 @@ impl Catalog {
|
||||
}
|
||||
|
||||
/// Resolve a decklist line to `(oracle_id, scryfall_id of its printing)`.
|
||||
/// Falls back to the whole text as the name when the suffix-stripped name
|
||||
/// doesn't resolve, in case the "suffix" was part of the card's name.
|
||||
fn resolve_line(&self, line: &DecklistLine) -> Result<Option<(String, Option<String>)>> {
|
||||
/// A named printing settles which card it is when several share the
|
||||
/// name (a card and its token, say). Falls back to the whole text as the
|
||||
/// name when the suffix-stripped name doesn't resolve, in case the
|
||||
/// "suffix" was part of the card's name.
|
||||
pub(crate) fn resolve_line(&self, line: &DecklistLine) -> Result<Option<(String, Option<String>)>> {
|
||||
if let Some(p) = &line.printing {
|
||||
let hit = self
|
||||
.conn
|
||||
.query_row(
|
||||
&format!(
|
||||
"SELECT p.oracle_id, p.scryfall_id FROM {REF_DB}.printings p
|
||||
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = p.oracle_id
|
||||
WHERE oc.name_lower = ?1 AND lower(p.set_code) = lower(?2)
|
||||
AND p.collector_number = ?3
|
||||
LIMIT 1"
|
||||
),
|
||||
params![line.name.trim().to_lowercase(), p.set_code, p.collector_number],
|
||||
|r| Ok((r.get(0)?, Some(r.get(1)?))),
|
||||
)
|
||||
.optional()?;
|
||||
if hit.is_some() {
|
||||
return Ok(hit);
|
||||
}
|
||||
}
|
||||
if let Some(oid) = self.find_oracle_id_by_name(&line.name)? {
|
||||
let printing = match &line.printing {
|
||||
Some(p) => self.find_printing(&oid, &p.set_code, &p.collector_number)?,
|
||||
@@ -422,6 +454,22 @@ impl Catalog {
|
||||
Ok(format_deck_text(&deck, &contents, &printings))
|
||||
}
|
||||
|
||||
/// Import a card list into the library: a plain-text list (ours, or
|
||||
/// ManaBox's text export) or a ManaBox CSV export, told apart by
|
||||
/// [`crate::manabox::is_manabox_csv`]. The whole import is one
|
||||
/// transaction, so a failure part-way leaves the library as it was.
|
||||
/// `on_progress(done, total)` is called after each line.
|
||||
pub fn import_card_list(&self, text: &str, mut on_progress: impl FnMut(usize, usize)) -> Result<LibraryImport> {
|
||||
let tx = self.nested_tx()?;
|
||||
let result = if crate::manabox::is_manabox_csv(text) {
|
||||
self.import_manabox_rows(&crate::manabox::parse_manabox_csv(text)?, &mut on_progress)?
|
||||
} else {
|
||||
self.import_library_lines(&parse_library_text(text), &mut on_progress)?
|
||||
};
|
||||
tx.commit()?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Import a plain-text library list, adding each resolved card's
|
||||
/// quantity to existing ownership (additive via [`Catalog::add_owned`]).
|
||||
/// A line naming a printing also adds its copies to that printing's copy
|
||||
@@ -429,40 +477,67 @@ impl Catalog {
|
||||
/// it, else its first finish. A finish the printing doesn't come in
|
||||
/// leaves those copies unspecified. 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<String>)> {
|
||||
let lines = parse_library_text(text);
|
||||
pub fn import_library_list(&self, text: &str) -> Result<LibraryImport> {
|
||||
let tx = self.nested_tx()?;
|
||||
let result = self.import_library_lines(&parse_library_text(text), &mut |_, _| {})?;
|
||||
tx.commit()?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn import_library_lines(
|
||||
&self,
|
||||
lines: &[DecklistLine],
|
||||
on_progress: &mut dyn FnMut(usize, usize),
|
||||
) -> Result<LibraryImport> {
|
||||
let mut added = Vec::new();
|
||||
let mut unresolved = Vec::new();
|
||||
for line in &lines {
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
on_progress(i, lines.len());
|
||||
let Some((oid, printing)) = self.resolve_line(line)? else {
|
||||
unresolved.push(line.name.clone());
|
||||
continue;
|
||||
};
|
||||
let new_qty = self.add_owned(&oid, line.quantity)?;
|
||||
if let Some(scryfall_id) = printing {
|
||||
let offered = self.printing_finishes(&oid, &scryfall_id)?;
|
||||
let wanted = line.printing.as_ref().and_then(|p| p.finish.clone());
|
||||
let finish = match wanted {
|
||||
Some(f) => (offered.is_empty() || offered.contains(&f)).then_some(f),
|
||||
None if offered.is_empty() || offered.iter().any(|f| f == "nonfoil") => {
|
||||
Some("nonfoil".to_string())
|
||||
}
|
||||
None => offered.first().cloned(),
|
||||
};
|
||||
if let Some(finish) = finish {
|
||||
let current = self
|
||||
.list_library_copies(&oid)?
|
||||
.into_iter()
|
||||
.find(|c| c.scryfall_id == scryfall_id && c.finish == finish)
|
||||
.map_or(0, |c| c.quantity);
|
||||
self.set_library_copy(&oid, &scryfall_id, &finish, current + line.quantity)?;
|
||||
}
|
||||
self.add_imported_copies(&oid, &scryfall_id, wanted, line.quantity)?;
|
||||
}
|
||||
added.push((line.name.clone(), new_qty));
|
||||
}
|
||||
on_progress(lines.len(), lines.len());
|
||||
Ok((added, unresolved))
|
||||
}
|
||||
|
||||
/// Add `quantity` already-owned copies of `oracle_id` to its
|
||||
/// `scryfall_id` copy group — in `wanted` finish, else nonfoil when the
|
||||
/// printing comes in it, else its first finish. A finish the printing
|
||||
/// doesn't come in leaves those copies unspecified.
|
||||
pub(crate) fn add_imported_copies(
|
||||
&self,
|
||||
oracle_id: &str,
|
||||
scryfall_id: &str,
|
||||
wanted: Option<String>,
|
||||
quantity: i64,
|
||||
) -> Result<()> {
|
||||
let offered = self.printing_finishes(oracle_id, scryfall_id)?;
|
||||
let finish = match wanted {
|
||||
Some(f) => (offered.is_empty() || offered.contains(&f)).then_some(f),
|
||||
None if offered.is_empty() || offered.iter().any(|f| f == "nonfoil") => {
|
||||
Some("nonfoil".to_string())
|
||||
}
|
||||
None => offered.first().cloned(),
|
||||
};
|
||||
if let Some(finish) = finish {
|
||||
let current = self
|
||||
.list_library_copies(oracle_id)?
|
||||
.into_iter()
|
||||
.find(|c| c.scryfall_id == scryfall_id && c.finish == finish)
|
||||
.map_or(0, |c| c.quantity);
|
||||
self.set_library_copy(oracle_id, scryfall_id, &finish, current + quantity)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render the given owned cards as library-export text: a line per
|
||||
/// tracked copy group, then one for the card's unspecified copies.
|
||||
pub fn export_library_text(&self, oracle_ids: &[String]) -> Result<String> {
|
||||
@@ -560,6 +635,60 @@ mod tests {
|
||||
assert_eq!((l.name.as_str(), l.printing), ("Sol Ring", None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_name_resolves_to_the_real_card_unless_a_printing_says_otherwise() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
add_printings(cat);
|
||||
// A token sharing Sol Ring's name, ahead of it in table order.
|
||||
cat.connection()
|
||||
.execute_batch(
|
||||
"INSERT INTO ref.oracle_cards(rowid, oracle_id, name, name_lower, type_line)
|
||||
VALUES (0, 'sol-token', 'Sol Ring', 'sol ring', 'Token Artifact');
|
||||
INSERT INTO ref.printings(scryfall_id, oracle_id, set_code, collector_number, finishes)
|
||||
VALUES ('sol-token-t2x2', 'sol-token', 't2x2', '315', 'nonfoil');",
|
||||
)
|
||||
.unwrap();
|
||||
let resolve = |text: &str| cat.resolve_line(&parse_card_line(text).unwrap()).unwrap().unwrap();
|
||||
assert_eq!(resolve("1 Sol Ring"), ("sol-ring".into(), None));
|
||||
assert_eq!(resolve("1 Sol Ring (2X2) 315"), ("sol-ring".into(), Some("sol-2x2".into())));
|
||||
assert_eq!(
|
||||
resolve("1 Sol Ring (T2X2) 315"),
|
||||
("sol-token".into(), Some("sol-token-t2x2".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_card_list_reads_either_format_and_reports_progress() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
let mut seen = Vec::new();
|
||||
let (added, _) = cat
|
||||
.import_card_list("2 Lightning Bolt\n1 Sol Ring\n", |done, total| seen.push((done, total)))
|
||||
.unwrap();
|
||||
assert_eq!(added.len(), 2);
|
||||
assert_eq!(seen, vec![(0, 2), (1, 2), (2, 2)]);
|
||||
|
||||
let csv = "Name,Quantity,Scryfall ID\nSerra Angel,3,\n";
|
||||
let (added, unresolved) = cat.import_card_list(csv, |_, _| {}).unwrap();
|
||||
assert!(unresolved.is_empty());
|
||||
assert_eq!(added, vec![("Serra Angel".to_string(), 3)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_inside_an_uncommitted_nested_tx_are_rolled_back() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
let tx = cat.nested_tx().unwrap();
|
||||
// Commits its own nested transaction, but the outer one never does.
|
||||
cat.import_library_list("2 Sol Ring\n").unwrap();
|
||||
drop(tx);
|
||||
assert!(cat.list_library().unwrap().is_empty());
|
||||
// The connection is usable again afterwards.
|
||||
cat.import_library_list("2 Sol Ring\n").unwrap();
|
||||
assert_eq!(cat.list_library().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_suffix_that_names_no_printing_still_imports_the_card() {
|
||||
let fx = test_support::make();
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod filters;
|
||||
pub(crate) mod http;
|
||||
pub mod ingest;
|
||||
pub mod library;
|
||||
pub mod manabox;
|
||||
pub mod model;
|
||||
pub mod news;
|
||||
pub mod printings;
|
||||
@@ -127,6 +128,44 @@ impl Catalog {
|
||||
pub fn connection(&self) -> &Connection {
|
||||
&self.conn
|
||||
}
|
||||
|
||||
/// Open a transaction that may nest inside another: a SAVEPOINT, which
|
||||
/// starts a transaction on its own and a nested one inside an open
|
||||
/// transaction. Lets a bulk operation wrap many single-card writes, each
|
||||
/// with its own transaction, in one commit.
|
||||
pub(crate) fn nested_tx(&self) -> Result<NestedTx<'_>> {
|
||||
self.conn.execute_batch("SAVEPOINT mtgc")?;
|
||||
Ok(NestedTx { conn: &self.conn, done: false })
|
||||
}
|
||||
}
|
||||
|
||||
/// See [`Catalog::nested_tx`]. Rolled back on drop unless committed.
|
||||
pub(crate) struct NestedTx<'a> {
|
||||
conn: &'a Connection,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
impl NestedTx<'_> {
|
||||
pub(crate) fn commit(mut self) -> Result<()> {
|
||||
self.conn.execute_batch("RELEASE mtgc")?;
|
||||
self.done = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for NestedTx<'_> {
|
||||
type Target = Connection;
|
||||
fn deref(&self) -> &Connection {
|
||||
self.conn
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NestedTx<'_> {
|
||||
fn drop(&mut self) {
|
||||
if !self.done {
|
||||
let _ = self.conn.execute_batch("ROLLBACK TO mtgc; RELEASE mtgc");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -64,7 +64,7 @@ impl Catalog {
|
||||
/// are trimmed, newest first — see [`Catalog::clamp_copies`].
|
||||
pub fn set_owned(&self, oracle_id: &str, quantity: i64) -> Result<i64> {
|
||||
let q = quantity.max(0);
|
||||
let tx = self.conn.unchecked_transaction()?;
|
||||
let tx = self.nested_tx()?;
|
||||
tx.execute(
|
||||
"INSERT INTO library_cards(oracle_id, quantity_owned) VALUES (?1, ?2)
|
||||
ON CONFLICT(oracle_id) DO UPDATE SET quantity_owned = excluded.quantity_owned",
|
||||
|
||||
363
crates/mtgc-core/src/manabox.rs
Normal file
363
crates/mtgc-core/src/manabox.rs
Normal file
@@ -0,0 +1,363 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -184,7 +184,7 @@ impl Catalog {
|
||||
}
|
||||
}
|
||||
|
||||
let tx = self.conn.unchecked_transaction()?;
|
||||
let tx = self.nested_tx()?;
|
||||
tx.execute(
|
||||
"INSERT INTO library_cards(oracle_id) VALUES (?1) ON CONFLICT(oracle_id) DO NOTHING",
|
||||
params![oracle_id],
|
||||
|
||||
@@ -434,10 +434,34 @@ pub fn export_library_cards(db: Db, oracle_ids: Vec<String>, path: String) -> Re
|
||||
std::fs::write(&path, text).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn import_library_cards(db: Db, path: String) -> Res<LibraryImportResult> {
|
||||
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 })
|
||||
/// How far a library import has got, for the progress bar.
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct LibraryImportProgress {
|
||||
pub done: usize,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
/// Import a card list (plain text or a ManaBox export) into the library.
|
||||
/// Runs on a blocking thread so the UI stays responsive, emitting
|
||||
/// `library-import-progress` as it goes.
|
||||
#[tauri::command]
|
||||
pub async fn import_library_cards(app: AppHandle, path: String) -> Res<LibraryImportResult> {
|
||||
crate::sync::blocking(move || {
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||
let s = crate::sync::lock(&app);
|
||||
// At most one event per percent, so a big file doesn't flood the UI.
|
||||
let mut last_percent = None;
|
||||
let (added, unresolved) = s
|
||||
.catalog()?
|
||||
.import_card_list(&text, |done, total| {
|
||||
let percent = (done * 100).checked_div(total).unwrap_or(100);
|
||||
if last_percent != Some(percent) {
|
||||
last_percent = Some(percent);
|
||||
let _ = app.emit("library-import-progress", LibraryImportProgress { done, total });
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(LibraryImportResult { added, unresolved })
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -301,6 +301,38 @@ export function InfoDialog({
|
||||
);
|
||||
}
|
||||
|
||||
/** Modal progress for work that can't be cancelled or dismissed (e.g. a
|
||||
* library import): a title, a bar, and "N of M <unit>". `total` 0 shows an
|
||||
* empty bar while the work is still starting. */
|
||||
export function ProgressDialog({
|
||||
title,
|
||||
done,
|
||||
total,
|
||||
unit,
|
||||
}: {
|
||||
title: string;
|
||||
done: number;
|
||||
total: number;
|
||||
unit: string;
|
||||
}) {
|
||||
const percent = total > 0 ? Math.round((done / total) * 100) : 0;
|
||||
return (
|
||||
<div className="dialog-backdrop">
|
||||
<div className="dialog" role="progressbar" aria-valuenow={percent} aria-valuemin={0} aria-valuemax={100}>
|
||||
<h3 className="dialog-title">{title}</h3>
|
||||
<div className="ingest-segment active progress-dialog-bar">
|
||||
<div className="ingest-segment-track">
|
||||
<div className="ingest-segment-fill" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="dialog-message muted">
|
||||
{total > 0 ? `${done.toLocaleString()} of ${total.toLocaleString()} ${unit}` : "Starting…"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Floating snackbar offering to reverse the caller's last action. Auto-
|
||||
* dismisses on a timer managed by the caller (this component is purely
|
||||
* presentational) — pass `null`/omit rendering it once the window lapses. */
|
||||
|
||||
@@ -735,6 +735,9 @@ input[type="checkbox"] {
|
||||
background: var(--border);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
.progress-dialog-bar {
|
||||
margin: var(--space-3) 0;
|
||||
}
|
||||
.ingest-segment.active .ingest-segment-fill {
|
||||
background: linear-gradient(90deg, var(--accent-hover), var(--accent));
|
||||
}
|
||||
@@ -1257,6 +1260,12 @@ li:focus-within .row-actions {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.dialog-list code {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 13px;
|
||||
color: var(--text-strong);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dialog-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
@@ -12,12 +13,14 @@ import {
|
||||
import {
|
||||
CardThumb,
|
||||
CardTile,
|
||||
DialogFrame,
|
||||
ColorPips,
|
||||
ColorToggles,
|
||||
GroupBySelect,
|
||||
GroupMode,
|
||||
InfoDialog,
|
||||
LocationField,
|
||||
ProgressDialog,
|
||||
PromptDialog,
|
||||
QtyStepper,
|
||||
SortBySelect,
|
||||
@@ -95,6 +98,14 @@ export function LibraryBrowser({
|
||||
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const [importResult, setImportResult] = useState<LibraryImportResult | null>(null);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
// The "what can I import?" explainer shown before the file picker.
|
||||
const [importIntroOpen, setImportIntroOpen] = useState(false);
|
||||
// Lines done / total while an import runs; null when idle.
|
||||
const [importProgress, setImportProgress] = useState<{ done: number; total: number } | null>(null);
|
||||
// Only show the progress dialog for an import that's taking a while, so a
|
||||
// quick one doesn't flash it up.
|
||||
const [showImportProgress, setShowImportProgress] = useState(false);
|
||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -306,14 +317,28 @@ export function LibraryBrowser({
|
||||
}
|
||||
|
||||
async function pickImportFile() {
|
||||
setImportIntroOpen(false);
|
||||
const path = await open({
|
||||
title: "Import library cards",
|
||||
filters: [{ name: "Decklist", extensions: ["txt"] }],
|
||||
filters: [{ name: "Card list (decklist or ManaBox export)", extensions: ["txt", "csv"] }],
|
||||
});
|
||||
if (typeof path !== "string") return;
|
||||
const result = await api.importLibraryCards(path);
|
||||
setImportResult(result);
|
||||
setRefreshKey((k) => k + 1);
|
||||
setImportProgress({ done: 0, total: 0 });
|
||||
const reveal = setTimeout(() => setShowImportProgress(true), 300);
|
||||
const unlisten = await listen<{ done: number; total: number }>("library-import-progress", (e) =>
|
||||
setImportProgress(e.payload),
|
||||
);
|
||||
try {
|
||||
setImportResult(await api.importLibraryCards(path));
|
||||
} catch (err) {
|
||||
setImportError(String(err));
|
||||
} finally {
|
||||
unlisten();
|
||||
clearTimeout(reveal);
|
||||
setShowImportProgress(false);
|
||||
setImportProgress(null);
|
||||
setRefreshKey((k) => k + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const deckOrLocation = (c: LibraryCard) =>
|
||||
@@ -455,7 +480,12 @@ export function LibraryBrowser({
|
||||
Library <span className="badge">{rows.length}</span>
|
||||
</h2>
|
||||
<div className="page-header-actions">
|
||||
<button onClick={pickImportFile}>Import cards…</button>
|
||||
<button
|
||||
onClick={() => setImportIntroOpen(true)}
|
||||
disabled={importProgress !== null}
|
||||
>
|
||||
Import cards…
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -554,6 +584,52 @@ export function LibraryBrowser({
|
||||
/>
|
||||
)}
|
||||
|
||||
{importIntroOpen && (
|
||||
<DialogFrame title="Import cards" onCancel={() => setImportIntroOpen(false)} wide>
|
||||
<p className="dialog-message">
|
||||
Adds the cards in a file to your library. Nothing you already own is replaced or
|
||||
removed. You can import:
|
||||
</p>
|
||||
<ul className="dialog-list">
|
||||
<li>
|
||||
<strong>A card list</strong> (.txt), one card per line, like <code>4 Lightning Bolt</code>.
|
||||
A line can name a printing, like <code>1 Sol Ring (2X2) 315 *F*</code>, where{" "}
|
||||
<code>*F*</code> is foil and <code>*E*</code> etched. Library export, Moxfield and
|
||||
Arena all write this format.
|
||||
</li>
|
||||
<li>
|
||||
<strong>A ManaBox export</strong> of a collection or binder, either the .csv or the
|
||||
.txt. The CSV records each copy's exact printing and finish, so it's the better
|
||||
choice. ManaBox's condition, language and price fields aren't kept.
|
||||
</li>
|
||||
</ul>
|
||||
<p className="dialog-message">Any cards that can't be matched are listed afterwards.</p>
|
||||
<div className="dialog-actions">
|
||||
<button onClick={() => setImportIntroOpen(false)}>Cancel</button>
|
||||
<button className="primary" onClick={pickImportFile} autoFocus>
|
||||
Choose file…
|
||||
</button>
|
||||
</div>
|
||||
</DialogFrame>
|
||||
)}
|
||||
|
||||
{importProgress && showImportProgress && (
|
||||
<ProgressDialog
|
||||
title="Importing cards…"
|
||||
done={importProgress.done}
|
||||
total={importProgress.total}
|
||||
unit="lines"
|
||||
/>
|
||||
)}
|
||||
|
||||
{importError && (
|
||||
<InfoDialog
|
||||
title="Couldn't import that file"
|
||||
message={importError}
|
||||
onClose={() => setImportError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{importResult && (
|
||||
<InfoDialog
|
||||
title="Cards imported"
|
||||
|
||||
Reference in New Issue
Block a user