diff --git a/README.md b/README.md index d8cd3a9..a4424b6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/mtgc-core/src/decklist.rs b/crates/mtgc-core/src/decklist.rs index aa824b6..86e6898 100644 --- a/crates/mtgc-core/src/decklist.rs +++ b/crates/mtgc-core/src/decklist.rs @@ -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); + /// One ` ` 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> { 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)>> { + /// 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)>> { + 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 { + 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)> { - let lines = parse_library_text(text); + pub fn import_library_list(&self, text: &str) -> Result { + 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 { 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, + 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 { @@ -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(); diff --git a/crates/mtgc-core/src/lib.rs b/crates/mtgc-core/src/lib.rs index 6735754..162115a 100644 --- a/crates/mtgc-core/src/lib.rs +++ b/crates/mtgc-core/src/lib.rs @@ -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> { + 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)] diff --git a/crates/mtgc-core/src/library.rs b/crates/mtgc-core/src/library.rs index 0ff9858..b143694 100644 --- a/crates/mtgc-core/src/library.rs +++ b/crates/mtgc-core/src/library.rs @@ -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 { 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", diff --git a/crates/mtgc-core/src/manabox.rs b/crates/mtgc-core/src/manabox.rs new file mode 100644 index 0000000..41ab343 --- /dev/null +++ b/crates/mtgc-core/src/manabox.rs @@ -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, + 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()); + } +} diff --git a/crates/mtgc-core/src/printings.rs b/crates/mtgc-core/src/printings.rs index 3f14e91..126155f 100644 --- a/crates/mtgc-core/src/printings.rs +++ b/crates/mtgc-core/src/printings.rs @@ -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], diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 33bf4d4..316cd3b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -434,10 +434,34 @@ pub fn export_library_cards(db: Db, oracle_ids: Vec, 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 { - 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 { + 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 } diff --git a/src/components.tsx b/src/components.tsx index 8b41c07..5325b9b 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -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 ". `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 ( +
+
+

{title}

+
+
+
+
+
+

+ {total > 0 ? `${done.toLocaleString()} of ${total.toLocaleString()} ${unit}` : "Starting…"} +

+
+
+ ); +} + /** 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. */ diff --git a/src/styles.css b/src/styles.css index b16c0d1..e34aedd 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; diff --git a/src/views/LibraryBrowser.tsx b/src/views/LibraryBrowser.tsx index b263180..8cbe1d4 100644 --- a/src/views/LibraryBrowser.tsx +++ b/src/views/LibraryBrowser.tsx @@ -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(null); + const [importError, setImportError] = useState(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(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 {rows.length}
- +
@@ -554,6 +584,52 @@ export function LibraryBrowser({ /> )} + {importIntroOpen && ( + setImportIntroOpen(false)} wide> +

+ Adds the cards in a file to your library. Nothing you already own is replaced or + removed. You can import: +

+
    +
  • + A card list (.txt), one card per line, like 4 Lightning Bolt. + A line can name a printing, like 1 Sol Ring (2X2) 315 *F*, where{" "} + *F* is foil and *E* etched. Library export, Moxfield and + Arena all write this format. +
  • +
  • + A ManaBox export 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. +
  • +
+

Any cards that can't be matched are listed afterwards.

+
+ + +
+
+ )} + + {importProgress && showImportProgress && ( + + )} + + {importError && ( + setImportError(null)} + /> + )} + {importResult && (