From 214a53558829be9dc2c08ef4e8b4e629a9a0c3ab Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Fri, 16 Oct 2026 21:06:17 -0500 Subject: [PATCH] Printing tracking for owned copies and deck art, and an outdated-card-data notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owned copies can now be pinned to a printing + finish (nonfoil / foil / etched), activating the long-dormant library_card_copies table as groups of identical copies. library_cards.quantity_owned stays authoritative: groups are a subset of it, and the rest are "unspecified". Tracking more copies than are unspecified grows ownership to fit; lowering ownership trims the most recently added groups first. Migration 0006 adds the group quantity, a unique (card, printing, finish) index, and deck_cards.preferred_scryfall_id. A deck slot can show a chosen printing's art, both faces from that printing, falling back to the default when the id no longer resolves. It survives quantity/board changes and is kept by duplicate_deck. UI: the click preview's info tile gets a Printings section when opened from the Library or a deck (copy groups with steppers, "Add a printing…", and "Shown in this deck" with Change…/Default), backed by a printing picker grid. The picker takes Escape in the capture phase so it closes without closing the preview under it. Sync: CardEntry.printings (keyed "/") and DeckCardEntry.printing. Cards now merge field by field with groups per key, so a quantity edit and a printing added on another device both survive; merged groups are clamped deterministically. A document is written as version 2 only once it holds printing data, so older builds and servers refuse it instead of dropping the data and syncing the loss. Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck and library export/import. Library export writes a line per group plus one for unspecified copies. reference.db printings gain lang and released_at for picker labels and ordering. When the installed card data lacks any column this build's reference schema has (found by diffing against the schema applied in-memory, so no list to maintain), a notice under the top bar offers Update card data (and Import card data… on phones); "Not now" is remembered until something more goes missing. --- README.md | 40 ++ crates/mtgc-core/src/db.rs | 77 +++ crates/mtgc-core/src/decklist.rs | 470 +++++++++++++--- crates/mtgc-core/src/decks.rs | 23 +- crates/mtgc-core/src/ingest.rs | 21 +- crates/mtgc-core/src/lib.rs | 24 +- crates/mtgc-core/src/library.rs | 8 +- crates/mtgc-core/src/model.rs | 37 ++ crates/mtgc-core/src/printings.rs | 525 ++++++++++++++++++ .../src/sql/library/0006_printings.sql | 15 + crates/mtgc-core/src/sql/reference_schema.sql | 6 +- crates/mtgc-core/src/sync.rs | 290 +++++++++- crates/mtgc-core/src/sync/remote.rs | 4 +- crates/mtgc-server/tests/sync_e2e.rs | 42 +- docs/printing-tracking-design.md | 43 +- src-tauri/src/commands.rs | 61 +- src-tauri/src/lib.rs | 4 + src/App.tsx | 32 ++ src/api.ts | 56 ++ src/components.tsx | 313 ++++++++++- src/styles.css | 100 ++++ src/views/DeckView.tsx | 23 +- src/views/LibraryBrowser.tsx | 16 +- 23 files changed, 2092 insertions(+), 138 deletions(-) create mode 100644 crates/mtgc-core/src/printings.rs create mode 100644 crates/mtgc-core/src/sql/library/0006_printings.sql diff --git a/README.md b/README.md index 473b1c8..af2177c 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,39 @@ Marvel Commander — isn't hidden. > once to enable them; until then they're inert rather than broken. Price, > format legality and *can be a commander* work immediately. +### Printings + +The library counts cards, not printings, but you can say which printing a copy +is when it matters. Click a card's image in the **Library** or a deck to open +its preview. The **Owned** section lists your copies by printing and finish +(nonfoil / foil / etched), with a stepper for each, and **Add a printing…** +picks another. Copies you haven't assigned a printing to stay *unspecified*: +"5 copies · 3 by printing · 2 unspecified". Assigning more copies to printings +than you own raises the owned count to match. Lowering the owned count drops +the most recently added printing entries first. + +In a deck, **Shown in this deck → Change…** picks which printing's art that +slot shows, and **Default** goes back to the usual one. This is display only: +it doesn't need to be a copy you own. + +Decklist export writes a chosen or tracked printing the way Moxfield and Arena +do, and import reads it back: + +```text +1 Sol Ring (2X2) 315 *F* +4 Lightning Bolt (2XM) 129 +``` + +`*F*` marks foil and `*E*` etched. A library export writes one line per +printing, then a plain line for the unspecified copies. A suffix that doesn't +match any printing still imports the card itself. + +> 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 +> **Update card data**, and **Import card data…** too on a phone. Dismissing it +> with **Not now** lasts until a later version needs something more. + ## Sync Your library always lives on the device you're using, so the app works the @@ -235,6 +268,13 @@ either is lost) or **use the server's library** instead of this device's. The Info tab also lists the account's signed-in devices, so a lost phone can be signed out from another one. +> **Printings and older versions.** A library with printings in it (see +> [Printings](#printings)) is saved in a newer format. A device or server +> running an older version refuses it and says to update, rather than syncing +> and quietly losing them. Libraries that don't use printings stay readable by +> older versions. Update the sync server before the first device starts using +> printings. + > **Upgrading from folder sync?** Earlier versions kept the library as a JSON > file in a folder your sync tool watched. Your library is already on each > device, so signing in to a server picks up from there; the first device to diff --git a/crates/mtgc-core/src/db.rs b/crates/mtgc-core/src/db.rs index 8447e2e..f44b53c 100644 --- a/crates/mtgc-core/src/db.rs +++ b/crates/mtgc-core/src/db.rs @@ -18,6 +18,7 @@ const LIBRARY_MIGRATIONS: &[&str] = &[ include_str!("sql/library/0003_deck_format.sql"), include_str!("sql/library/0004_deck_maybeboard.sql"), include_str!("sql/library/0005_deck_uuid.sql"), + include_str!("sql/library/0006_printings.sql"), ]; /// The attached alias under which the reference db is available at runtime. @@ -160,6 +161,50 @@ pub fn has_reference_column(conn: &Connection, table: &str, column: &str) -> Res )? > 0) } +/// Columns the reference schema this build ships has, but the attached +/// reference db lacks, as `"table.column"` — i.e. what a re-ingest would add. +/// Empty means the card data is current. +/// +/// Derived by applying [`REFERENCE_SCHEMA`] to a scratch in-memory db and +/// comparing, so a column added to the schema is picked up here with no list +/// to keep in step. FTS5's internal shadow tables are skipped; they belong to +/// SQLite, not to the schema. +pub fn missing_reference_columns(conn: &Connection) -> Result> { + let expected = Connection::open_in_memory()?; + expected.execute_batch(REFERENCE_SCHEMA)?; + let all: Vec<(String, String)> = { + let mut stmt = expected.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table'")?; + let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?; + rows.collect::>()? + }; + let virtual_tables: Vec<&str> = all + .iter() + .filter(|(_, sql)| sql.starts_with("CREATE VIRTUAL")) + .map(|(name, _)| name.as_str()) + .collect(); + // Ordinary tables only: neither a virtual table nor one of its + // `_data`/`_idx`/... shadow tables. + let tables = all.iter().map(|(name, _)| name).filter(|name| { + !virtual_tables + .iter() + .any(|v| *name == v || name.starts_with(&format!("{v}_"))) + }); + let mut missing = Vec::new(); + for table in tables { + let columns: Vec = { + let mut stmt = expected.prepare(&format!("SELECT name FROM pragma_table_info('{table}')"))?; + let rows = stmt.query_map([], |r| r.get(0))?; + rows.collect::>()? + }; + for column in columns { + if !has_reference_column(conn, table, &column)? { + missing.push(format!("{table}.{column}")); + } + } + } + Ok(missing) +} + /// Apply any library migrations newer than the recorded `user_version`. pub fn migrate_library(conn: &Connection) -> Result<()> { let current: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?; @@ -219,6 +264,38 @@ mod tests { assert_eq!(hit, 1); } + #[test] + fn card_data_built_from_this_schema_is_not_outdated() { + let dir = tempfile::tempdir().unwrap(); + let reference = dir.path().join("reference.db"); + open_reference_for_build(&reference).unwrap(); + let conn = Connection::open_in_memory().unwrap(); + attach_reference(&conn, &reference).unwrap(); + assert_eq!(missing_reference_columns(&conn).unwrap(), Vec::::new()); + } + + #[test] + fn card_data_from_an_older_schema_reports_what_it_lacks() { + let dir = tempfile::tempdir().unwrap(); + let reference = dir.path().join("reference.db"); + { + // As an older ingester left it: printings without the picker's + // newer columns. + let older = REFERENCE_SCHEMA + .replace(" lang TEXT NOT NULL DEFAULT '',\n", "") + .replace(",\n -- Scryfall language", "\n -- Scryfall language") + .replace(" released_at TEXT NOT NULL DEFAULT ''\n", ""); + assert_ne!(older, REFERENCE_SCHEMA, "fixture must actually drop the columns"); + Connection::open(&reference).unwrap().execute_batch(&older).unwrap(); + } + let conn = Connection::open_in_memory().unwrap(); + attach_reference(&conn, &reference).unwrap(); + assert_eq!( + missing_reference_columns(&conn).unwrap(), + vec!["printings.lang".to_string(), "printings.released_at".to_string()] + ); + } + #[test] fn inspect_reference_db_accepts_a_real_db_and_rejects_bad_ones() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/mtgc-core/src/decklist.rs b/crates/mtgc-core/src/decklist.rs index b765738..aa824b6 100644 --- a/crates/mtgc-core/src/decklist.rs +++ b/crates/mtgc-core/src/decklist.rs @@ -25,8 +25,21 @@ //! `Nx`/`N×` prefix (`4x Foo`). Unrecognized lines are silently skipped, //! never a hard error — see [`parse_deck_text`]. //! +//! A line may name a printing the way Moxfield and Arena do, with an +//! optional finish marker after it (`*F*` foil, `*E*` etched): +//! ```text +//! 1 Sol Ring (2X2) 315 *F* +//! ``` +//! Deck export writes the suffix for a slot with a chosen printing; deck +//! import restores that choice. A suffix that doesn't match a printing of the +//! card still imports the card itself. +//! //! Library export format is the same comment/quantity-name line convention -//! with no sections — see [`parse_library_text`]. +//! 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. + +use std::collections::HashMap; use rusqlite::{params, OptionalExtension}; @@ -40,6 +53,40 @@ use crate::Catalog; pub struct DecklistLine { pub quantity: i64, pub name: String, + /// The printing named by a `(SET) NUM` suffix, if the line had one. + pub printing: Option, +} + +/// A printing as a decklist names it: set code and collector number, plus a +/// finish marker when one followed. +#[derive(Debug, Clone, PartialEq)] +pub struct LinePrinting { + /// As written (decklists usually uppercase it; Scryfall stores lowercase). + pub set_code: String, + pub collector_number: String, + /// "foil" or "etched" from a `*F*`/`*E*` marker; `None` when unmarked. + pub finish: Option, +} + +impl DecklistLine { + /// The name exactly as the line spelled it, suffix included — the + /// fallback for the (rare) card whose real name ends in a parenthesis. + fn unparsed_name(&self) -> Option { + self.printing + .as_ref() + .map(|p| format!("{} ({}) {}", self.name, p.set_code, p.collector_number)) + } +} + +/// The ` (SET) NUM` suffix for a printing, with the set uppercased as +/// decklist tools write it, plus a finish marker for foil/etched. +fn printing_suffix(set_code: &str, collector_number: &str, finish: Option<&str>) -> String { + let marker = match finish { + Some("foil") => " *F*", + Some("etched") => " *E*", + _ => "", + }; + format!(" ({}) {collector_number}{marker}", set_code.to_uppercase()) } /// The structured result of parsing deck-export text. @@ -111,10 +158,49 @@ pub fn parse_deck_text(text: &str) -> ParsedDeckText { parsed } +/// Split a trailing printing suffix — `(SET) NUM`, optionally followed by +/// `*F*`/`*E*` — off a card name. Returns the name unchanged and `None` when +/// there isn't one. +fn split_printing_suffix(text: &str) -> (&str, Option) { + let mut rest = text.trim(); + let mut finish = None; + for (marker, f) in [("*F*", "foil"), ("*E*", "etched")] { + if rest.len() >= marker.len() && rest[rest.len() - marker.len()..].eq_ignore_ascii_case(marker) { + rest = rest[..rest.len() - marker.len()].trim_end(); + finish = Some(f.to_string()); + break; + } + } + let parsed = (|| { + let (before, number) = rest.rsplit_once(char::is_whitespace)?; + if number.contains(['(', ')']) { + return None; + } + let before = before.trim_end().strip_suffix(')')?; + let (name, set) = before.rsplit_once('(')?; + let name = name.trim_end(); + let valid_set = (2..=6).contains(&set.len()) && set.chars().all(|c| c.is_ascii_alphanumeric()); + (valid_set && !name.is_empty()).then_some((name, set, number)) + })(); + match parsed { + Some((name, set, number)) => ( + name, + Some(LinePrinting { + set_code: set.to_string(), + collector_number: number.to_string(), + finish, + }), + ), + // A finish marker with no printing to attach it to means nothing. + None => (rest, None), + } +} + /// Parse a single ` ` line. Accepts a bare integer quantity -/// (`4 Lightning Bolt`) or an `Nx`/`N×` prefix (`4x Lightning Bolt`). -/// Returns `None` (skip, don't error) if the line doesn't start with a -/// recognizable positive quantity or has no name after it. +/// (`4 Lightning Bolt`) or an `Nx`/`N×` prefix (`4x Lightning Bolt`), and an +/// optional printing suffix (see the module docs). Returns `None` (skip, +/// don't error) if the line doesn't start with a recognizable positive +/// quantity or has no name after it. fn parse_card_line(line: &str) -> Option { let (head, rest) = line.split_once(char::is_whitespace)?; let head_lower = head.to_lowercase(); @@ -126,13 +212,14 @@ fn parse_card_line(line: &str) -> Option { if quantity <= 0 { return None; } - let name = rest.trim(); + let (name, printing) = split_printing_suffix(rest); if name.is_empty() { return None; } Some(DecklistLine { quantity, name: name.to_string(), + printing, }) } @@ -149,7 +236,15 @@ pub fn parse_library_text(text: &str) -> Vec { /// Render a deck (metadata + contents) as export text. Empty sections (no /// commander, no maybeboard entries) are omitted entirely. The /// `// Format: X` comment is emitted only when `deck.format` is non-empty. -pub fn format_deck_text(deck: &Deck, contents: &[DeckEntry]) -> String { +/// +/// `printings` maps a scryfall id to its `(set_code, collector_number)`, for +/// the slots with a chosen printing; a slot whose printing is missing from it +/// is written without a suffix. +pub fn format_deck_text( + deck: &Deck, + contents: &[DeckEntry], + printings: &HashMap, +) -> String { let mut out = String::from("// mtgc deck export\n"); if !deck.format.is_empty() { out.push_str(&format!("// Format: {}\n", deck.format)); @@ -162,33 +257,47 @@ pub fn format_deck_text(deck: &Deck, contents: &[DeckEntry]) -> String { .filter(|e| !e.is_commander && !e.is_maybeboard) .collect(); - if !commander.is_empty() { - out.push_str("\nCommander\n"); - for e in &commander { - out.push_str(&format!("{} {}\n", e.quantity, e.card.name)); - } - } - if !mainboard.is_empty() { - out.push_str("\nDeck\n"); - for e in &mainboard { - out.push_str(&format!("{} {}\n", e.quantity, e.card.name)); - } - } - if !maybeboard.is_empty() { - out.push_str("\nMaybeboard\n"); - for e in &maybeboard { - out.push_str(&format!("{} {}\n", e.quantity, e.card.name)); + let line = |e: &DeckEntry| { + let suffix = e + .preferred_scryfall_id + .as_ref() + .and_then(|id| printings.get(id)) + .map(|(set, num)| printing_suffix(set, num, None)) + .unwrap_or_default(); + format!("{} {}{suffix}\n", e.quantity, e.card.name) + }; + for (header, entries) in [("Commander", &commander), ("Deck", &mainboard), ("Maybeboard", &maybeboard)] { + if !entries.is_empty() { + out.push_str(&format!("\n{header}\n")); + for e in entries { + out.push_str(&line(e)); + } } } out } -/// Render `(name, quantity)` pairs as library-export text. -pub fn format_library_text(entries: &[(String, i64)]) -> String { +/// One line of a library export: a quantity of a card, optionally pinned to +/// a printing + finish. +#[derive(Debug, Clone, PartialEq)] +pub struct LibraryExportLine { + pub name: String, + pub quantity: i64, + /// `(set_code, collector_number, finish)` for a tracked copy group. + pub printing: Option<(String, String, String)>, +} + +/// Render library lines as library-export text. +pub fn format_library_text(entries: &[LibraryExportLine]) -> String { let mut out = String::from("// mtgc library export\n"); - for (name, qty) in entries { - out.push_str(&format!("{qty} {name}\n")); + for e in entries { + let suffix = e + .printing + .as_ref() + .map(|(set, num, finish)| printing_suffix(set, num, Some(finish))) + .unwrap_or_default(); + out.push_str(&format!("{} {}{suffix}\n", e.quantity, e.name)); } out } @@ -209,6 +318,44 @@ impl Catalog { .optional()?) } + /// 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)>> { + 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)?, + None => None, + }; + return Ok(Some((oid, printing))); + } + match line.unparsed_name() { + Some(full) => Ok(self.find_oracle_id_by_name(&full)?.map(|oid| (oid, None))), + None => Ok(None), + } + } + + /// `(set_code, collector_number)` for each of `scryfall_ids` the + /// reference db knows. + fn printing_numbers<'a>( + &self, + scryfall_ids: impl Iterator, + ) -> Result> { + let mut stmt = self.conn.prepare(&format!( + "SELECT set_code, collector_number FROM {REF_DB}.printings WHERE scryfall_id = ?1" + ))?; + let mut out = HashMap::new(); + for id in scryfall_ids { + if let Some(pair) = stmt + .query_row(params![id], |r| Ok((r.get(0)?, r.get(1)?))) + .optional()? + { + out.insert(id.clone(), pair); + } + } + Ok(out) + } + /// Import a decklist as a brand-new deck named `name`. Always creates a /// new deck — never merges into an existing one. Best-effort: card /// names that don't resolve are skipped (not a hard failure) and @@ -227,35 +374,37 @@ impl Catalog { let mut unresolved = Vec::new(); let mut commander_oracle_id: Option = None; - for (i, line) in parsed.commander.iter().enumerate() { - match self.find_oracle_id_by_name(&line.name)? { - Some(oid) => { - let is_first = i == 0; - self.set_deck_card(deck.id, &oid, line.quantity, is_first, false)?; - if is_first { - commander_oracle_id = Some(oid); - } + { + let mut add = |line: &DecklistLine, is_commander: bool, is_maybeboard: bool| -> Result> { + let Some((oid, printing)) = self.resolve_line(line)? else { + unresolved.push(line.name.clone()); + return Ok(None); + }; + self.set_deck_card(deck.id, &oid, line.quantity, is_commander, is_maybeboard)?; + if let Some(id) = printing { + self.set_deck_card_printing(deck.id, &oid, Some(&id))?; } - None => unresolved.push(line.name.clone()), + Ok(Some(oid)) + }; + + for (i, line) in parsed.commander.iter().enumerate() { + let is_first = i == 0; + let oid = add(line, is_first, false)?; + if is_first { + commander_oracle_id = oid; + } + } + for line in &parsed.mainboard { + add(line, false, false)?; + } + for line in &parsed.maybeboard { + add(line, false, true)?; } } if let Some(oid) = &commander_oracle_id { self.set_deck_commander(deck.id, Some(oid))?; } - for line in &parsed.mainboard { - match self.find_oracle_id_by_name(&line.name)? { - Some(oid) => self.set_deck_card(deck.id, &oid, line.quantity, false, false)?, - None => unresolved.push(line.name.clone()), - } - } - for line in &parsed.maybeboard { - match self.find_oracle_id_by_name(&line.name)? { - Some(oid) => self.set_deck_card(deck.id, &oid, line.quantity, false, true)?, - None => unresolved.push(line.name.clone()), - } - } - let deck = self .get_deck(deck.id)? .expect("just-imported deck must exist"); @@ -268,33 +417,56 @@ impl Catalog { .get_deck(deck_id)? .ok_or_else(|| crate::error::CoreError::Other(format!("deck {deck_id} not found")))?; let contents = self.deck_contents(deck_id)?; - Ok(format_deck_text(&deck, &contents)) + let printings = + self.printing_numbers(contents.iter().filter_map(|e| e.preferred_scryfall_id.as_ref()))?; + Ok(format_deck_text(&deck, &contents, &printings)) } /// Import a plain-text library list, adding each resolved card's /// quantity to existing ownership (additive via [`Catalog::add_owned`]). - /// Returns `(name, new_total_quantity_owned)` per applied line, plus - /// unresolved names. + /// A line naming a printing also adds its copies to that printing's copy + /// group — in the marked 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. 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); let mut added = Vec::new(); let mut unresolved = Vec::new(); for line in &lines { - match self.find_oracle_id_by_name(&line.name)? { - Some(oid) => { - let new_qty = self.add_owned(&oid, line.quantity)?; - added.push((line.name.clone(), new_qty)); + 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)?; } - None => unresolved.push(line.name.clone()), } + added.push((line.name.clone(), new_qty)); } Ok((added, unresolved)) } - /// Render the given owned cards (name + current quantity_owned) as - /// library-export text. + /// 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 { - let mut entries = Vec::with_capacity(oracle_ids.len()); + let mut cards = Vec::with_capacity(oracle_ids.len()); for oid in oracle_ids { let row: Option<(String, i64)> = self .conn @@ -308,12 +480,31 @@ impl Catalog { |r| Ok((r.get(0)?, r.get(1)?)), ) .optional()?; - if let Some(entry) = row { - entries.push(entry); + if let Some((name, owned)) = row { + cards.push((oid, name, owned)); } } - entries.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); - Ok(format_library_text(&entries)) + cards.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase())); + + let mut lines = Vec::new(); + for (oid, name, owned) in cards { + let mut unspecified = owned; + for copy in self.list_library_copies(oid)? { + // A group whose printing has left the reference db can't be + // named, so its copies are written as unspecified. + let Some(p) = copy.printing else { continue }; + unspecified -= copy.quantity; + lines.push(LibraryExportLine { + name: name.clone(), + quantity: copy.quantity, + printing: Some((p.set_code, p.collector_number, copy.finish)), + }); + } + if unspecified > 0 { + lines.push(LibraryExportLine { name, quantity: unspecified, printing: None }); + } + } + Ok(format_library_text(&lines)) } } @@ -322,19 +513,152 @@ mod tests { use super::*; use crate::test_support; + fn line(quantity: i64, name: &str) -> DecklistLine { + DecklistLine { quantity, name: name.into(), printing: None } + } + + fn printed(set: &str, num: &str, finish: Option<&str>) -> Option { + Some(LinePrinting { + set_code: set.into(), + collector_number: num.into(), + finish: finish.map(str::to_string), + }) + } + + /// Give Sol Ring a 2X2 printing (nonfoil + foil) and Lightning Bolt a + /// 2XM one (nonfoil 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');", + ) + .unwrap(); + } + + #[test] + fn parse_card_line_reads_printing_suffixes() { + let l = parse_card_line("1 Sol Ring (2X2) 315 *F*").unwrap(); + assert_eq!(l.name, "Sol Ring"); + assert_eq!(l.printing, printed("2X2", "315", Some("foil"))); + + let l = parse_card_line("4x Lightning Bolt (2XM) 129").unwrap(); + assert_eq!(l.name, "Lightning Bolt"); + assert_eq!(l.printing, printed("2XM", "129", None)); + + let l = parse_card_line("1 Sol Ring (PLST) C21-263 *e*").unwrap(); + assert_eq!(l.printing, printed("PLST", "C21-263", Some("etched"))); + + // A name that merely contains parentheses is left alone. + let l = parse_card_line("1 B.F.M. (Big Furry Monster)").unwrap(); + assert_eq!(l.name, "B.F.M. (Big Furry Monster)"); + assert_eq!(l.printing, None); + + // A finish marker with no printing is dropped. + let l = parse_card_line("1 Sol Ring *F*").unwrap(); + assert_eq!((l.name.as_str(), l.printing), ("Sol Ring", None)); + } + + #[test] + fn a_suffix_that_names_no_printing_still_imports_the_card() { + let fx = test_support::make(); + let cat = &fx.catalog; + let (deck, unresolved) = cat.import_deck("Odd", "1 Sol Ring (ZZZ) 1\n").unwrap(); + assert!(unresolved.is_empty()); + let contents = cat.deck_contents(deck.id).unwrap(); + assert_eq!(contents[0].card.oracle_id, "sol-ring"); + assert_eq!(contents[0].preferred_scryfall_id, None); + } + + #[test] + fn deck_printings_round_trip_through_export_and_import() { + let fx = test_support::make(); + let cat = &fx.catalog; + add_printings(cat); + let deck = cat.create_deck("Art", None).unwrap(); + cat.set_deck_card(deck.id, "sol-ring", 1, false, false).unwrap(); + cat.set_deck_card(deck.id, "lightning-bolt", 4, false, true).unwrap(); + cat.set_deck_card(deck.id, "serra-angel", 1, false, false).unwrap(); + cat.set_deck_card_printing(deck.id, "sol-ring", Some("sol-2x2")).unwrap(); + cat.set_deck_card_printing(deck.id, "lightning-bolt", Some("bolt-2xm")).unwrap(); + + let text = cat.export_deck_text(deck.id).unwrap(); + assert!(text.contains("1 Sol Ring (2X2) 315\n"), "{text}"); + assert!(text.contains("4 Lightning Bolt (2XM) 129\n"), "{text}"); + assert!(text.contains("1 Serra Angel\n"), "{text}"); + + let (copy, unresolved) = cat.import_deck("Art again", &text).unwrap(); + assert!(unresolved.is_empty()); + let mut chosen: Vec<_> = cat + .deck_contents(copy.id) + .unwrap() + .into_iter() + .map(|e| (e.card.oracle_id, e.preferred_scryfall_id)) + .collect(); + chosen.sort(); + assert_eq!( + chosen, + vec![ + ("lightning-bolt".into(), Some("bolt-2xm".into())), + ("serra-angel".into(), None), + ("sol-ring".into(), Some("sol-2x2".into())), + ] + ); + } + + #[test] + fn library_copies_round_trip_through_export_and_import() { + let fx = test_support::make(); + let cat = &fx.catalog; + add_printings(cat); + cat.set_owned("sol-ring", 4).unwrap(); + cat.set_library_copy("sol-ring", "sol-2x2", "nonfoil", 1).unwrap(); + cat.set_library_copy("sol-ring", "sol-2x2", "foil", 2).unwrap(); + + let text = cat.export_library_text(&["sol-ring".to_string()]).unwrap(); + assert_eq!( + text, + "// mtgc library export\n1 Sol Ring (2X2) 315\n2 Sol Ring (2X2) 315 *F*\n1 Sol Ring\n" + ); + + // Import into an empty library reproduces it. + cat.set_owned("sol-ring", 0).unwrap(); + assert!(cat.list_library_copies("sol-ring").unwrap().is_empty()); + let (_, unresolved) = cat.import_library_list(&text).unwrap(); + assert!(unresolved.is_empty()); + assert_eq!(cat.export_library_text(&["sol-ring".to_string()]).unwrap(), text); + } + + #[test] + fn library_import_defaults_and_rejects_finishes_sensibly() { + let fx = test_support::make(); + let cat = &fx.catalog; + add_printings(cat); + // Bolt's 2XM printing is nonfoil only: the foil copy is still owned, + // just not pinned to a printing it can't be. + cat.import_library_list("1 Lightning Bolt (2XM) 129 *F*\n2 Lightning Bolt (2xm) 129\n").unwrap(); + let copies = cat.list_library_copies("lightning-bolt").unwrap(); + assert_eq!(copies.len(), 1); + assert_eq!((copies[0].finish.as_str(), copies[0].quantity), ("nonfoil", 2)); + let lib = cat.list_library().unwrap(); + let bolt = lib.iter().find(|c| c.card.oracle_id == "lightning-bolt").unwrap(); + assert_eq!(bolt.quantity_owned, 3); + } + #[test] fn parse_card_line_accepts_bare_and_x_prefixed_quantities() { assert_eq!( parse_card_line("4 Lightning Bolt"), - Some(DecklistLine { quantity: 4, name: "Lightning Bolt".into() }) + Some(line(4, "Lightning Bolt")) ); assert_eq!( parse_card_line("4x Lightning Bolt"), - Some(DecklistLine { quantity: 4, name: "Lightning Bolt".into() }) + Some(line(4, "Lightning Bolt")) ); assert_eq!( parse_card_line("4× Lightning Bolt"), - Some(DecklistLine { quantity: 4, name: "Lightning Bolt".into() }) + Some(line(4, "Lightning Bolt")) ); assert_eq!(parse_card_line("not a card line"), None); assert_eq!(parse_card_line("0 Lightning Bolt"), None); @@ -347,12 +671,12 @@ mod tests { assert_eq!(parsed.format.as_deref(), Some("Commander")); assert_eq!( parsed.commander, - vec![DecklistLine { quantity: 1, name: "Krenko, Mob Boss".into() }] + vec![line(1, "Krenko, Mob Boss")] ); - assert_eq!(parsed.mainboard, vec![DecklistLine { quantity: 1, name: "Sol Ring".into() }]); + assert_eq!(parsed.mainboard, vec![line(1, "Sol Ring")]); assert_eq!( parsed.maybeboard, - vec![DecklistLine { quantity: 1, name: "Some Card".into() }] + vec![line(1, "Some Card")] ); } @@ -373,7 +697,7 @@ mod tests { commander_oracle_id: None, notes: String::new(), }; - let text = format_deck_text(&deck, &[]); + let text = format_deck_text(&deck, &[], &HashMap::new()); assert_eq!(text, "// mtgc deck export\n"); } @@ -432,15 +756,15 @@ mod tests { assert_eq!(parsed.format.as_deref(), Some("Commander")); assert_eq!( parsed.commander, - vec![DecklistLine { quantity: 1, name: "Serra Angel".into() }] + vec![line(1, "Serra Angel")] ); assert_eq!( parsed.mainboard, - vec![DecklistLine { quantity: 4, name: "Lightning Bolt".into() }] + vec![line(4, "Lightning Bolt")] ); assert_eq!( parsed.maybeboard, - vec![DecklistLine { quantity: 1, name: "Sol Ring".into() }] + vec![line(1, "Sol Ring")] ); } diff --git a/crates/mtgc-core/src/decks.rs b/crates/mtgc-core/src/decks.rs index 308d304..6ef9645 100644 --- a/crates/mtgc-core/src/decks.rs +++ b/crates/mtgc-core/src/decks.rs @@ -60,7 +60,7 @@ impl Catalog { } /// Duplicate a deck under a new name: same format/commander/notes, plus a - /// copy of every deck_cards row (quantity + commander flag). Fails if + /// copy of every deck_cards row (quantity, flags, chosen printing). Fails if /// `new_name` is already taken. pub fn duplicate_deck(&self, id: i64, new_name: &str) -> Result { let src = self @@ -75,8 +75,9 @@ impl Catalog { )?; let new_id = self.conn.last_insert_rowid(); self.conn.execute( - "INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard) - SELECT ?1, oracle_id, quantity, is_commander, is_maybeboard FROM deck_cards WHERE deck_id = ?2", + "INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id) + SELECT ?1, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id + FROM deck_cards WHERE deck_id = ?2", params![new_id, id], )?; self.get_deck(new_id) @@ -84,7 +85,8 @@ impl Catalog { } /// Set how many copies of a card belong to a deck. `quantity <= 0` removes - /// the card from the deck. Upserts on (deck_id, oracle_id). + /// the card from the deck. Upserts on (deck_id, oracle_id), leaving any + /// chosen printing in place. pub fn set_deck_card( &self, deck_id: i64, @@ -115,15 +117,23 @@ impl Catalog { /// All cards in a deck — owned or not — with their in-deck quantity and how /// many copies are owned (0 if the card isn't in the library). Callers split /// this into "in your library" vs "not owned" groups. + /// + /// A slot with a chosen printing shows that printing's art — both faces + /// from it, so a double-faced card never mixes two printings. An id that + /// no longer resolves (or has no image) falls back to the default. pub fn deck_contents(&self, deck_id: i64) -> Result> { let mut stmt = self.conn.prepare(&format!( "SELECT dc.quantity, dc.is_commander, COALESCE(lc.quantity_owned, 0), oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity, - oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless, - oc.legalities, {price}, dc.is_maybeboard, {horizontal} + oc.type_line, + COALESCE(NULLIF(pp.image_uri, ''), {image}), + CASE WHEN pp.image_uri <> '' THEN pp.back_image_uri ELSE {back} END, + oc.produced_mana, oc.produces_colorless, + oc.legalities, {price}, dc.is_maybeboard, {horizontal}, dc.preferred_scryfall_id FROM deck_cards dc JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id LEFT JOIN library_cards lc ON lc.oracle_id = dc.oracle_id + LEFT JOIN {REF_DB}.printings pp ON pp.scryfall_id = dc.preferred_scryfall_id WHERE dc.deck_id = ?1 ORDER BY oc.name COLLATE NOCASE", image = self.image_expr("image_uri"), @@ -153,6 +163,7 @@ impl Catalog { horizontal: r.get::<_, i64>(16)? != 0, }, is_maybeboard: r.get::<_, i64>(15)? != 0, + preferred_scryfall_id: r.get(17)?, }) })? .collect::, _>>()?; diff --git a/crates/mtgc-core/src/ingest.rs b/crates/mtgc-core/src/ingest.rs index d65b30e..3fcffb4 100644 --- a/crates/mtgc-core/src/ingest.rs +++ b/crates/mtgc-core/src/ingest.rs @@ -732,6 +732,10 @@ struct ScryPrinting { /// Scryfall's set category; `"funny"` covers the Un-sets. #[serde(default)] set_type: String, + #[serde(default)] + lang: String, + #[serde(default)] + released_at: String, } #[derive(Deserialize)] @@ -758,8 +762,8 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) -> { let mut ins = conn.prepare( "INSERT OR IGNORE INTO printings - (scryfall_id,oracle_id,set_code,collector_number,finishes,rarity,image_uri,back_image_uri,price_usd,universes_beyond,digital,funny) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)", + (scryfall_id,oracle_id,set_code,collector_number,finishes,rarity,image_uri,back_image_uri,price_usd,universes_beyond,digital,funny,lang,released_at) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)", )?; stream_jsonl::<_, ScryPrinting, _>(reader, |p| { if cancel.is_cancelled() { @@ -814,6 +818,8 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) -> universes_beyond as i64, digital as i64, funny as i64, + p.lang, + p.released_at, ])?; count += 1; Ok(()) @@ -1036,6 +1042,7 @@ mod tests { let printings = r#"[ {"id":"p-1","oracle_id":"oid-1","set":"m19","collector_number":"310", "finishes":["nonfoil","foil"],"rarity":"common", + "lang":"en","released_at":"2018-07-13", "image_uris":{"normal":"http://img/1"}} ]"#; @@ -1067,6 +1074,16 @@ mod tests { ) .unwrap(); assert_eq!(g as u8, crate::model::G); + + // Picker labels: language and release date. + let (lang, released): (String, String) = conn + .query_row( + "SELECT lang, released_at FROM printings WHERE scryfall_id='p-1'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!((lang.as_str(), released.as_str()), ("en", "2018-07-13")); } #[test] diff --git a/crates/mtgc-core/src/lib.rs b/crates/mtgc-core/src/lib.rs index 703e287..6735754 100644 --- a/crates/mtgc-core/src/lib.rs +++ b/crates/mtgc-core/src/lib.rs @@ -15,6 +15,7 @@ pub mod ingest; pub mod library; pub mod model; pub mod news; +pub mod printings; pub mod search; pub mod sync; pub mod synergy; @@ -25,8 +26,8 @@ use rusqlite::Connection; pub use error::{CoreError, Result}; pub use model::{ - CardRef, ColorSet, Deck, DeckEntry, LegalStatus, Legalities, LibraryCard, LibraryFacets, - LibraryFilter, LibraryStats, + CardRef, ColorSet, Deck, DeckEntry, LegalStatus, Legalities, LibraryCard, LibraryCopy, + LibraryFacets, LibraryFilter, LibraryStats, PrintingRef, }; pub use search::NameIndex; pub use synergy::{CardSuggestion, SuggestOptions, SynergyReason}; @@ -54,6 +55,14 @@ pub struct Catalog { /// [`Self::has_universes_beyond`]: it shipped in a later reference-schema /// revision, so a db can have one without the other. pub(crate) has_vanilla: bool, + /// Whether `printings.released_at` (and its `lang` sibling) exist — the + /// printing picker's labels and ordering. Another later reference-schema + /// revision; without it the picker still works, just less informatively. + pub(crate) has_printing_details: bool, + /// What a re-ingest would add to the attached reference db — see + /// [`db::missing_reference_columns`]. Cached at open time; the reference db + /// only changes by being replaced, which reopens the catalog. + missing_reference_columns: Vec, } impl Catalog { @@ -66,12 +75,16 @@ impl Catalog { let has_universes_beyond = db::has_reference_column(&conn, "oracle_cards", "universes_beyond")?; let has_vanilla = db::has_reference_column(&conn, "oracle_cards", "vanilla")?; + let has_printing_details = db::has_reference_column(&conn, "printings", "released_at")?; + let missing_reference_columns = db::missing_reference_columns(&conn)?; Ok(Self { conn, has_horizontal_column, has_oracle_images, has_universes_beyond, has_vanilla, + has_printing_details, + missing_reference_columns, }) } @@ -96,6 +109,13 @@ impl Catalog { db::image_expr(self.has_oracle_images, column) } + /// Reference-schema columns the attached card data lacks (`"table.column"`), + /// i.e. it was built by an older version and some features are degraded + /// until the user updates it. Empty when it's current. + pub fn missing_reference_columns(&self) -> &[String] { + &self.missing_reference_columns + } + /// Build (or rebuild) the in-memory fuzzy name index from the attached /// reference db. Cheap enough to hold in app state and rebuild after a /// reference-data update. diff --git a/crates/mtgc-core/src/library.rs b/crates/mtgc-core/src/library.rs index 18ed915..0ff9858 100644 --- a/crates/mtgc-core/src/library.rs +++ b/crates/mtgc-core/src/library.rs @@ -60,14 +60,18 @@ impl Catalog { } /// Set the absolute owned quantity for a card (0 removes ownership but keeps - /// any deck assignments referencing it). + /// any deck assignments referencing it). Copy groups that no longer fit + /// are trimmed, newest first — see [`Catalog::clamp_copies`]. pub fn set_owned(&self, oracle_id: &str, quantity: i64) -> Result { let q = quantity.max(0); - self.conn.execute( + let tx = self.conn.unchecked_transaction()?; + 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", params![oracle_id, q], )?; + self.clamp_copies(oracle_id)?; + tx.commit()?; Ok(q) } diff --git a/crates/mtgc-core/src/model.rs b/crates/mtgc-core/src/model.rs index 77ca2be..94f992f 100644 --- a/crates/mtgc-core/src/model.rs +++ b/crates/mtgc-core/src/model.rs @@ -210,6 +210,43 @@ pub struct DeckEntry { pub is_maybeboard: bool, /// Copies owned in the library (0 if the card isn't in the library). pub quantity_owned: i64, + /// The printing whose art this deck slot shows, if one was chosen. The + /// card's `image_uri`/`back_image_uri` already reflect it (falling back + /// to the default when the id no longer resolves). + pub preferred_scryfall_id: Option, +} + +/// One printing of a card, for the printing picker and copy labels. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PrintingRef { + pub scryfall_id: String, + /// Scryfall's set code, lowercase (e.g. "2x2"). + pub set_code: String, + pub collector_number: String, + /// Language code ("en", "ja", ...); empty on an older reference.db. + pub lang: String, + /// The finishes this printing exists in: "nonfoil", "foil", "etched". + pub finishes: Vec, + pub rarity: String, + pub image_uri: String, + pub back_image_uri: String, + /// Scryfall's `prices.usd` for this printing, 0 if unknown. + pub price_usd: f64, + /// Release date as YYYY-MM-DD; empty on an older reference.db. + pub released_at: String, +} + +/// A group of identical owned copies pinned to one printing + finish. Groups +/// are a subset of the card's `quantity_owned`; copies not in any group are +/// simply unspecified. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LibraryCopy { + pub scryfall_id: String, + pub finish: String, + pub quantity: i64, + /// The printing's details, or `None` when the id no longer resolves in + /// the reference db (e.g. after a re-ingest dropped it). + pub printing: Option, } impl LibraryCard { diff --git a/crates/mtgc-core/src/printings.rs b/crates/mtgc-core/src/printings.rs new file mode 100644 index 0000000..3f14e91 --- /dev/null +++ b/crates/mtgc-core/src/printings.rs @@ -0,0 +1,525 @@ +//! Per-printing detail layered over the oracle-level library: which printings +//! a card has, which printing + finish owned copies are, and which printing's +//! art a deck slot shows. +//! +//! `library_cards.quantity_owned` stays the authoritative total. Copy groups +//! (`library_card_copies`) annotate a subset of it, and two rules keep them +//! consistent with that total: +//! +//! - **Grow:** tracking more copies of a printing than are unspecified raises +//! `quantity_owned` to fit — recording a copy means you own it. +//! - **Clamp:** lowering `quantity_owned` below the tracked total trims the +//! most recently added groups first ([`Catalog::clamp_copies`]). Lowering a +//! group never lowers `quantity_owned`; those copies become unspecified. + +use rusqlite::{params, OptionalExtension}; + +use crate::db::REF_DB; +use crate::error::{CoreError, Result}; +use crate::model::{LibraryCopy, PrintingRef}; +use crate::Catalog; + +/// The finishes a copy can be tracked as — Scryfall's `finishes` values. +pub const FINISHES: [&str; 3] = ["nonfoil", "foil", "etched"]; + +/// Map the ten printing columns starting at `at` (`scryfall_id, set_code, +/// collector_number, finishes, rarity, image_uri, back_image_uri, price_usd, +/// lang, released_at`) into a [`PrintingRef`], or `None` when `scryfall_id` +/// is NULL (an unmatched LEFT JOIN). +fn row_to_printing(r: &rusqlite::Row, at: usize) -> rusqlite::Result> { + let Some(scryfall_id) = r.get::<_, Option>(at)? else { + return Ok(None); + }; + let finishes: String = r.get(at + 3)?; + Ok(Some(PrintingRef { + scryfall_id, + set_code: r.get(at + 1)?, + collector_number: r.get(at + 2)?, + finishes: split_finishes(&finishes), + rarity: r.get(at + 4)?, + image_uri: r.get(at + 5)?, + back_image_uri: r.get(at + 6)?, + price_usd: r.get(at + 7)?, + lang: r.get(at + 8)?, + released_at: r.get(at + 9)?, + })) +} + +fn split_finishes(joined: &str) -> Vec { + joined + .split(',') + .filter(|f| !f.is_empty()) + .map(str::to_string) + .collect() +} + +impl Catalog { + /// The printing columns [`row_to_printing`] reads, for the printing + /// aliased `p`. `lang`/`released_at` read as empty on a reference.db + /// built before they shipped. + fn printing_columns(&self) -> &'static str { + if self.has_printing_details { + "p.scryfall_id, p.set_code, p.collector_number, p.finishes, p.rarity, + p.image_uri, p.back_image_uri, p.price_usd, p.lang, p.released_at" + } else { + "p.scryfall_id, p.set_code, p.collector_number, p.finishes, p.rarity, + p.image_uri, p.back_image_uri, p.price_usd, '', ''" + } + } + + /// Every paper printing of a card, newest first — the printing picker. + /// Arena/MTGO-only printings are left out, since no physical copy can be + /// one (and a deck's art should be something you could own). + pub fn list_printings(&self, oracle_id: &str) -> Result> { + // `digital` predates `released_at`, so a db can have the filter but + // not the ordering. Checked here rather than cached on open: only the + // picker needs it. + let filter = if crate::db::has_reference_column(&self.conn, "printings", "digital")? { + "AND p.digital = 0" + } else { + "" + }; + let order = if self.has_printing_details { + "p.released_at DESC, p.rowid" + } else { + "p.rowid" + }; + let mut stmt = self.conn.prepare(&format!( + "SELECT {cols} FROM {REF_DB}.printings p + WHERE p.oracle_id = ?1 {filter} + ORDER BY {order}", + cols = self.printing_columns() + ))?; + let rows = stmt + .query_map(params![oracle_id], |r| row_to_printing(r, 0))? + .collect::, _>>()?; + Ok(rows.into_iter().flatten().collect()) + } + + /// The printing of `oracle_id` with this set code (case-insensitive) and + /// collector number — how decklists name a printing (`(2X2) 315`). + pub fn find_printing( + &self, + oracle_id: &str, + set_code: &str, + collector_number: &str, + ) -> Result> { + Ok(self + .conn + .query_row( + &format!( + "SELECT scryfall_id FROM {REF_DB}.printings + WHERE oracle_id = ?1 AND lower(set_code) = lower(?2) AND collector_number = ?3 + LIMIT 1" + ), + params![oracle_id, set_code, collector_number], + |r| r.get(0), + ) + .optional()?) + } + + /// A card's tracked copy groups, oldest first. + pub fn list_library_copies(&self, oracle_id: &str) -> Result> { + let mut stmt = self.conn.prepare(&format!( + "SELECT c.scryfall_id, c.finish, c.quantity, {cols} + FROM library_card_copies c + JOIN library_cards lc ON lc.id = c.library_card_id + LEFT JOIN {REF_DB}.printings p ON p.scryfall_id = c.scryfall_id + WHERE lc.oracle_id = ?1 + ORDER BY c.id", + cols = self.printing_columns() + ))?; + let rows = stmt + .query_map(params![oracle_id], |r| { + Ok(LibraryCopy { + scryfall_id: r.get(0)?, + finish: r.get(1)?, + quantity: r.get(2)?, + printing: row_to_printing(r, 3)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + } + + /// Check that `scryfall_id` is a printing of `oracle_id` and return the + /// finishes it comes in (empty when Scryfall lists none). + pub(crate) fn printing_finishes(&self, oracle_id: &str, scryfall_id: &str) -> Result> { + let finishes: Option = self + .conn + .query_row( + &format!( + "SELECT finishes FROM {REF_DB}.printings WHERE scryfall_id = ?1 AND oracle_id = ?2" + ), + params![scryfall_id, oracle_id], + |r| r.get(0), + ) + .optional()?; + finishes + .map(|f| split_finishes(&f)) + .ok_or_else(|| CoreError::Other("that isn't a printing of this card".into())) + } + + /// Set how many owned copies of a card are `scryfall_id` in `finish`. + /// `quantity <= 0` removes the group (never validated against the + /// reference db, so a group whose printing has since vanished can still + /// be removed). Grows `quantity_owned` when the groups would otherwise + /// exceed it. Returns the card's owned quantity afterwards. + pub fn set_library_copy( + &self, + oracle_id: &str, + scryfall_id: &str, + finish: &str, + quantity: i64, + ) -> Result { + if quantity > 0 { + if !FINISHES.contains(&finish) { + return Err(CoreError::Other(format!("unknown finish \"{finish}\""))); + } + let finishes = self.printing_finishes(oracle_id, scryfall_id)?; + if !finishes.is_empty() && !finishes.iter().any(|f| f == finish) { + return Err(CoreError::Other(format!( + "that printing doesn't come in {finish}" + ))); + } + } + + let tx = self.conn.unchecked_transaction()?; + tx.execute( + "INSERT INTO library_cards(oracle_id) VALUES (?1) ON CONFLICT(oracle_id) DO NOTHING", + params![oracle_id], + )?; + let card_id: i64 = tx.query_row( + "SELECT id FROM library_cards WHERE oracle_id = ?1", + params![oracle_id], + |r| r.get(0), + )?; + if quantity <= 0 { + tx.execute( + "DELETE FROM library_card_copies + WHERE library_card_id = ?1 AND scryfall_id = ?2 AND finish = ?3", + params![card_id, scryfall_id, finish], + )?; + } else { + tx.execute( + "INSERT INTO library_card_copies(library_card_id, scryfall_id, finish, quantity) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(library_card_id, scryfall_id, finish) + DO UPDATE SET quantity = excluded.quantity", + params![card_id, scryfall_id, finish, quantity], + )?; + } + tx.execute( + "UPDATE library_cards SET quantity_owned = MAX(quantity_owned, + (SELECT COALESCE(SUM(quantity), 0) FROM library_card_copies WHERE library_card_id = ?1)) + WHERE id = ?1", + params![card_id], + )?; + let owned: i64 = tx.query_row( + "SELECT quantity_owned FROM library_cards WHERE id = ?1", + params![card_id], + |r| r.get(0), + )?; + tx.commit()?; + Ok(owned) + } + + /// Trim a card's copy groups until they fit within `quantity_owned`, + /// most recently added first: the newest annotation is the likeliest to + /// be the copy that just left, and older entries are kept intact. + pub(crate) fn clamp_copies(&self, oracle_id: &str) -> Result<()> { + let Some((card_id, owned)) = self + .conn + .query_row( + "SELECT id, quantity_owned FROM library_cards WHERE oracle_id = ?1", + params![oracle_id], + |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)), + ) + .optional()? + else { + return Ok(()); + }; + let groups: Vec<(i64, i64)> = { + let mut stmt = self.conn.prepare( + "SELECT id, quantity FROM library_card_copies WHERE library_card_id = ?1 ORDER BY id", + )?; + let rows = stmt.query_map(params![card_id], |r| Ok((r.get(0)?, r.get(1)?)))?; + rows.collect::>()? + }; + let mut excess = groups.iter().map(|(_, q)| q).sum::() - owned; + for (id, qty) in groups.iter().rev() { + if excess <= 0 { + break; + } + let cut = (*qty).min(excess); + if cut == *qty { + self.conn + .execute("DELETE FROM library_card_copies WHERE id = ?1", params![id])?; + } else { + self.conn.execute( + "UPDATE library_card_copies SET quantity = ?1 WHERE id = ?2", + params![qty - cut, id], + )?; + } + excess -= cut; + } + Ok(()) + } + + /// Choose which printing's art a deck slot shows, or `None` for the + /// card's default. Fails if the card isn't in the deck. + pub fn set_deck_card_printing( + &self, + deck_id: i64, + oracle_id: &str, + scryfall_id: Option<&str>, + ) -> Result<()> { + if let Some(id) = scryfall_id { + self.printing_finishes(oracle_id, id)?; + } + let changed = self.conn.execute( + "UPDATE deck_cards SET preferred_scryfall_id = ?1 WHERE deck_id = ?2 AND oracle_id = ?3", + params![scryfall_id, deck_id, oracle_id], + )?; + if changed == 0 { + return Err(CoreError::Other("that card isn't in this deck".into())); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::test_support; + use crate::Catalog; + + /// Give Sol Ring two extra printings alongside the fixture's own + /// ("print-sol-ring", no finishes listed, no date). + fn add_sol_ring_printings(cat: &Catalog) { + cat.connection() + .execute_batch( + "INSERT INTO ref.printings + (scryfall_id, oracle_id, set_code, collector_number, finishes, image_uri, back_image_uri, released_at, digital) + VALUES + ('sol-2x2', 'sol-ring', '2x2', '315', 'nonfoil,foil', 'http://img/2x2', '', '2022-07-08', 0), + ('sol-c21', 'sol-ring', 'c21', '263', 'nonfoil', 'http://img/c21', 'http://img/c21-back', '2021-04-23', 0), + ('sol-arena', 'sol-ring', 'ymid', '1', 'nonfoil', 'http://img/arena', '', '2023-01-01', 1);", + ) + .unwrap(); + } + + #[test] + fn list_printings_is_newest_first_and_paper_only() { + let fx = test_support::make(); + let cat = &fx.catalog; + add_sol_ring_printings(cat); + + let ids: Vec<_> = cat + .list_printings("sol-ring") + .unwrap() + .into_iter() + .map(|p| p.scryfall_id) + .collect(); + // The fixture printing has no date, so it sorts last. + assert_eq!(ids, vec!["sol-2x2", "sol-c21", "print-sol-ring"]); + + let p = &cat.list_printings("sol-ring").unwrap()[0]; + assert_eq!(p.finishes, vec!["nonfoil", "foil"]); + assert_eq!(p.set_code, "2x2"); + } + + #[test] + fn find_printing_matches_set_case_insensitively() { + let fx = test_support::make(); + let cat = &fx.catalog; + add_sol_ring_printings(cat); + assert_eq!( + cat.find_printing("sol-ring", "2X2", "315").unwrap().as_deref(), + Some("sol-2x2") + ); + assert_eq!(cat.find_printing("sol-ring", "2X2", "999").unwrap(), None); + // Right set and number, wrong card. + assert_eq!(cat.find_printing("lightning-bolt", "2X2", "315").unwrap(), None); + } + + #[test] + fn copy_groups_track_several_printings_of_one_card() { + let fx = test_support::make(); + let cat = &fx.catalog; + add_sol_ring_printings(cat); + cat.set_owned("sol-ring", 5).unwrap(); + + cat.set_library_copy("sol-ring", "sol-2x2", "nonfoil", 2).unwrap(); + cat.set_library_copy("sol-ring", "sol-2x2", "foil", 1).unwrap(); + cat.set_library_copy("sol-ring", "sol-c21", "nonfoil", 1).unwrap(); + + let copies = cat.list_library_copies("sol-ring").unwrap(); + let summary: Vec<_> = copies + .iter() + .map(|c| (c.scryfall_id.as_str(), c.finish.as_str(), c.quantity)) + .collect(); + assert_eq!( + summary, + vec![("sol-2x2", "nonfoil", 2), ("sol-2x2", "foil", 1), ("sol-c21", "nonfoil", 1)] + ); + assert_eq!(copies[0].printing.as_ref().unwrap().set_code, "2x2"); + + // Tracking within the owned total doesn't change it. + let lib = cat.list_library().unwrap(); + assert_eq!(lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap().quantity_owned, 5); + + // Setting a group again updates it in place; zero removes it. + cat.set_library_copy("sol-ring", "sol-2x2", "nonfoil", 3).unwrap(); + cat.set_library_copy("sol-ring", "sol-c21", "nonfoil", 0).unwrap(); + let copies = cat.list_library_copies("sol-ring").unwrap(); + assert_eq!(copies.len(), 2); + assert_eq!(copies[0].quantity, 3); + } + + #[test] + fn tracking_more_copies_than_owned_grows_ownership() { + let fx = test_support::make(); + let cat = &fx.catalog; + add_sol_ring_printings(cat); + cat.set_owned("sol-ring", 1).unwrap(); + + assert_eq!(cat.set_library_copy("sol-ring", "sol-2x2", "foil", 3).unwrap(), 3); + // Even for a card not yet in the library at all. + assert_eq!(cat.set_library_copy("lightning-bolt", "print-lightning-bolt", "nonfoil", 2).unwrap(), 2); + + // Lowering a group never lowers ownership. + assert_eq!(cat.set_library_copy("sol-ring", "sol-2x2", "foil", 1).unwrap(), 3); + } + + #[test] + fn lowering_ownership_trims_the_newest_groups_first() { + let fx = test_support::make(); + let cat = &fx.catalog; + add_sol_ring_printings(cat); + cat.set_owned("sol-ring", 5).unwrap(); + cat.set_library_copy("sol-ring", "sol-2x2", "nonfoil", 2).unwrap(); // oldest + cat.set_library_copy("sol-ring", "sol-2x2", "foil", 1).unwrap(); + cat.set_library_copy("sol-ring", "sol-c21", "nonfoil", 2).unwrap(); // newest + + // 5 tracked, now 3 owned: dropping the newest group (2) is enough. + cat.set_owned("sol-ring", 3).unwrap(); + let q: Vec<_> = cat + .list_library_copies("sol-ring") + .unwrap() + .into_iter() + .map(|c| (c.scryfall_id, c.finish, c.quantity)) + .collect(); + assert_eq!( + q, + vec![("sol-2x2".into(), "nonfoil".into(), 2), ("sol-2x2".into(), "foil".into(), 1)] + ); + + // Through the decrement path too, partially trimming a group. + cat.add_owned("sol-ring", -2).unwrap(); + let q: Vec<_> = cat + .list_library_copies("sol-ring") + .unwrap() + .into_iter() + .map(|c| (c.finish, c.quantity)) + .collect(); + assert_eq!(q, vec![("nonfoil".into(), 1)]); + } + + #[test] + fn copies_are_validated_against_the_card_and_its_finishes() { + let fx = test_support::make(); + let cat = &fx.catalog; + add_sol_ring_printings(cat); + + // A printing of a different card. + assert!(cat.set_library_copy("sol-ring", "print-lightning-bolt", "nonfoil", 1).is_err()); + // A finish this printing doesn't come in. + assert!(cat.set_library_copy("sol-ring", "sol-c21", "foil", 1).is_err()); + // Not a finish at all. + assert!(cat.set_library_copy("sol-ring", "sol-2x2", "shiny", 1).is_err()); + // A printing that lists no finishes accepts any real one. + cat.set_library_copy("sol-ring", "print-sol-ring", "etched", 1).unwrap(); + } + + #[test] + fn a_copy_whose_printing_vanished_is_listed_and_removable() { + let fx = test_support::make(); + let cat = &fx.catalog; + add_sol_ring_printings(cat); + cat.set_library_copy("sol-ring", "sol-c21", "nonfoil", 1).unwrap(); + // A re-ingest that no longer has it. + cat.connection() + .execute("DELETE FROM ref.printings WHERE scryfall_id = 'sol-c21'", []) + .unwrap(); + + let copies = cat.list_library_copies("sol-ring").unwrap(); + assert_eq!(copies.len(), 1); + assert!(copies[0].printing.is_none()); + + cat.set_library_copy("sol-ring", "sol-c21", "nonfoil", 0).unwrap(); + assert!(cat.list_library_copies("sol-ring").unwrap().is_empty()); + } + + #[test] + fn a_deck_slot_shows_its_chosen_printing() { + let fx = test_support::make(); + let cat = &fx.catalog; + add_sol_ring_printings(cat); + let d = cat.create_deck("Art", None).unwrap(); + cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap(); + + let sol = |cat: &Catalog| { + cat.deck_contents(d.id) + .unwrap() + .into_iter() + .find(|e| e.card.oracle_id == "sol-ring") + .unwrap() + }; + + // Unset: the default image. + assert_eq!(sol(cat).card.image_uri, "http://img/sol-ring"); + assert_eq!(sol(cat).preferred_scryfall_id, None); + + // Set: both faces come from the chosen printing. + cat.set_deck_card_printing(d.id, "sol-ring", Some("sol-c21")).unwrap(); + let e = sol(cat); + assert_eq!(e.preferred_scryfall_id.as_deref(), Some("sol-c21")); + assert_eq!(e.card.image_uri, "http://img/c21"); + assert_eq!(e.card.back_image_uri, "http://img/c21-back"); + + // Survives a quantity/board change. + cat.set_deck_card(d.id, "sol-ring", 2, false, true).unwrap(); + assert_eq!(sol(cat).card.image_uri, "http://img/c21"); + + // Kept by a duplicate. + let copy = cat.duplicate_deck(d.id, "Art copy").unwrap(); + let dup = cat.deck_contents(copy.id).unwrap(); + assert_eq!(dup[0].preferred_scryfall_id.as_deref(), Some("sol-c21")); + + // Dangling after a re-ingest: falls back to the default. + cat.connection() + .execute("DELETE FROM ref.printings WHERE scryfall_id = 'sol-c21'", []) + .unwrap(); + let e = sol(cat); + assert_eq!(e.card.image_uri, "http://img/sol-ring"); + assert_eq!(e.card.back_image_uri, ""); + + // Cleared. + cat.set_deck_card_printing(d.id, "sol-ring", None).unwrap(); + assert_eq!(sol(cat).preferred_scryfall_id, None); + } + + #[test] + fn deck_printing_is_validated() { + let fx = test_support::make(); + let cat = &fx.catalog; + add_sol_ring_printings(cat); + let d = cat.create_deck("Art", None).unwrap(); + cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap(); + + // Not a printing of this card. + assert!(cat.set_deck_card_printing(d.id, "sol-ring", Some("print-lightning-bolt")).is_err()); + // Card not in the deck. + assert!(cat.set_deck_card_printing(d.id, "lightning-bolt", Some("print-lightning-bolt")).is_err()); + } +} diff --git a/crates/mtgc-core/src/sql/library/0006_printings.sql b/crates/mtgc-core/src/sql/library/0006_printings.sql new file mode 100644 index 0000000..4d89d63 --- /dev/null +++ b/crates/mtgc-core/src/sql/library/0006_printings.sql @@ -0,0 +1,15 @@ +-- v6 — optional per-printing tracking (see docs/printing-tracking-design.md). +-- +-- `library_card_copies` has existed since v1 but was never written. Each row now +-- means "`quantity` of my copies of this card are this printing + finish": a +-- group of identical copies rather than one row per physical card. Groups are +-- a subset of `library_cards.quantity_owned`, which stays authoritative for +-- totals; any copies not covered by a group are simply unspecified. +ALTER TABLE library_card_copies ADD COLUMN quantity INTEGER NOT NULL DEFAULT 1; +CREATE UNIQUE INDEX idx_copies_printing + ON library_card_copies(library_card_id, scryfall_id, finish); + +-- Which printing's art to show for a deck slot. A pure display preference: +-- printings live in the rebuildable reference.db, so there's no foreign key, +-- and an id that no longer resolves just falls back to the default image. +ALTER TABLE deck_cards ADD COLUMN preferred_scryfall_id TEXT; diff --git a/crates/mtgc-core/src/sql/reference_schema.sql b/crates/mtgc-core/src/sql/reference_schema.sql index 381e95c..2caf1b9 100644 --- a/crates/mtgc-core/src/sql/reference_schema.sql +++ b/crates/mtgc-core/src/sql/reference_schema.sql @@ -94,7 +94,11 @@ CREATE TABLE IF NOT EXISTS printings ( -- Boolean: this printing isn't available on paper (Scryfall `games`). digital INTEGER NOT NULL DEFAULT 0, -- Boolean: this printing comes from a "funny" set (Scryfall `set_type`). - funny INTEGER NOT NULL DEFAULT 0 + funny INTEGER NOT NULL DEFAULT 0, + -- Scryfall language code ("en", "ja", ...) and release date (YYYY-MM-DD), + -- for labelling and ordering printings in the printing picker. + lang TEXT NOT NULL DEFAULT '', + released_at TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_printings_oid ON printings(oracle_id); diff --git a/crates/mtgc-core/src/sync.rs b/crates/mtgc-core/src/sync.rs index 859838e..5c8db40 100644 --- a/crates/mtgc-core/src/sync.rs +++ b/crates/mtgc-core/src/sync.rs @@ -48,9 +48,18 @@ use crate::error::Result; pub mod account; pub mod remote; -/// Bumped only for a breaking change to the document shape. Readers reject -/// anything newer rather than silently misreading it. -pub const DOC_VERSION: u32 = 1; +/// The newest document format this build reads. Readers reject anything newer +/// rather than silently misreading it. +/// +/// A document is written at the *lowest* version that can hold it (see +/// [`LibraryDoc::required_version`]), not at this one. A reader that silently +/// dropped printing data would publish its loss as a deletion on the next +/// sync, so documents that carry any are marked 2 and older builds refuse +/// them — while libraries that don't use printings stay readable everywhere. +pub const DOC_VERSION: u32 = 2; + +/// The format of a document with no printing data in it. +const BASE_VERSION: u32 = 1; /// One owned card. Keyed by `oracle_id` in [`LibraryDoc::cards`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -58,6 +67,10 @@ pub struct CardEntry { pub quantity: i64, #[serde(default, skip_serializing_if = "String::is_empty")] pub location: String, + /// Copies pinned to a printing + finish, keyed by [`printing_key`]. A + /// subset of `quantity`; whatever they don't cover is unspecified. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub printings: BTreeMap, } /// One card in a deck. Keyed by `oracle_id` in [`DeckDoc::cards`]. @@ -68,6 +81,20 @@ pub struct DeckCardEntry { pub commander: bool, #[serde(default, skip_serializing_if = "is_false")] pub maybeboard: bool, + /// Scryfall id of the printing whose art this slot shows. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub printing: Option, +} + +/// The [`CardEntry::printings`] key for a copy group: `"/"`. +/// Scryfall ids are UUIDs, so the separator can't be ambiguous. +pub fn printing_key(scryfall_id: &str, finish: &str) -> String { + format!("{scryfall_id}/{finish}") +} + +/// Split a [`printing_key`] back into `(scryfall_id, finish)`. +pub fn parse_printing_key(key: &str) -> Option<(&str, &str)> { + key.rsplit_once('/') } /// One deck. Keyed by its uuid in [`LibraryDoc::decks`] — see migration 0005 @@ -91,8 +118,6 @@ pub struct DeckDoc { /// always produces byte-identical JSON, which keeps sync tools from seeing /// spurious changes and makes the file diffable by hand. /// -/// `library_card_copies` is deliberately absent — that table is unused by any -/// code in the project. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LibraryDoc { pub version: u32, @@ -105,7 +130,7 @@ pub struct LibraryDoc { impl Default for LibraryDoc { fn default() -> Self { Self { - version: DOC_VERSION, + version: BASE_VERSION, cards: BTreeMap::new(), decks: BTreeMap::new(), } @@ -139,6 +164,22 @@ impl LibraryDoc { pub fn is_empty(&self) -> bool { self.cards.is_empty() && self.decks.is_empty() } + + /// The lowest format that can hold this document: 2 once it carries any + /// printing data, else 1. See [`DOC_VERSION`]. + pub fn required_version(&self) -> u32 { + let has_copies = self.cards.values().any(|c| !c.printings.is_empty()); + let has_deck_printings = self + .decks + .values() + .flat_map(|d| d.cards.values()) + .any(|c| c.printing.is_some()); + if has_copies || has_deck_printings { + 2 + } else { + BASE_VERSION + } + } } /// What a merge did, for reporting to the user. @@ -180,9 +221,7 @@ pub fn merge3(base: &LibraryDoc, ours: &LibraryDoc, theirs: &LibraryDoc) -> (Lib for id in keys(&base.cards, &ours.cards, &theirs.cards) { let (b, o, t) = (base.cards.get(&id), ours.cards.get(&id), theirs.cards.get(&id)); - if let Some(entry) = merge_entry(b, o, t, &mut || { - report.conflicts.push(format!("owned quantity for card {id}")); - }) { + if let Some(entry) = merge_card(b, o, t, &id, &mut report) { if Some(&entry) != o { report.cards_changed += 1; } @@ -222,6 +261,7 @@ pub fn merge3(base: &LibraryDoc, ours: &LibraryDoc, theirs: &LibraryDoc) -> (Lib out.decks.insert(id, merged); } + out.version = out.required_version(); (out, report) } @@ -273,6 +313,71 @@ fn merge_entry( } } +/// Three-way merge of one owned card. Whether it exists at all is decided +/// exactly as for any entry ([`merge_entry`]); when both sides kept it, its +/// fields merge independently and its copy groups per printing, so a quantity +/// change on one device and a printing recorded on another both survive. +fn merge_card( + base: Option<&CardEntry>, + ours: Option<&CardEntry>, + theirs: Option<&CardEntry>, + id: &str, + report: &mut MergeReport, +) -> Option { + let (Some(o), Some(t)) = (ours, theirs) else { + return merge_entry(base, ours, theirs, &mut || { + report.conflicts.push(format!("owned quantity for card {id}")) + }); + }; + if o == t { + return Some(o.clone()); + } + let empty = CardEntry::default(); + let b = base.unwrap_or(&empty); + let mut merged = CardEntry { + quantity: merge_field(&b.quantity, &o.quantity, &t.quantity, || { + report.conflicts.push(format!("owned quantity for card {id}")) + }), + location: merge_field(&b.location, &o.location, &t.location, || { + report.conflicts.push(format!("location of card {id}")) + }), + printings: BTreeMap::new(), + }; + for key in keys(&b.printings, &o.printings, &t.printings) { + let qty = merge_entry( + b.printings.get(&key), + o.printings.get(&key), + t.printings.get(&key), + &mut || report.conflicts.push(format!("tracked printing of card {id}")), + ); + if let Some(qty) = qty { + merged.printings.insert(key, qty); + } + } + clamp_printings(&mut merged); + Some(merged) +} + +/// Trim copy groups until they fit within the owned quantity — the merge's +/// counterpart to the database's own clamp. Trims from the end of key order +/// rather than "newest first", because a document has no edit order and the +/// result must come out the same on every device. +fn clamp_printings(card: &mut CardEntry) { + let mut excess = card.printings.values().sum::() - card.quantity.max(0); + while excess > 0 { + let Some(mut last) = card.printings.last_entry() else { + break; + }; + let cut = (*last.get()).min(excess); + if cut == *last.get() { + last.remove(); + } else { + *last.get_mut() -= cut; + } + excess -= cut; + } +} + fn merge_deck( base: Option<&DeckDoc>, ours: Option<&DeckDoc>, @@ -358,10 +463,26 @@ pub fn read_doc(conn: &Connection) -> Result { CardEntry { quantity: row.get(1)?, location: row.get(2)?, + printings: BTreeMap::new(), }, ); } + let mut stmt = conn.prepare( + "SELECT lc.oracle_id, c.scryfall_id, c.finish, c.quantity + FROM library_card_copies c JOIN library_cards lc ON lc.id = c.library_card_id + WHERE c.quantity > 0 AND c.scryfall_id IS NOT NULL AND c.finish IS NOT NULL", + )?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let oracle_id: String = row.get(0)?; + let key = printing_key(&row.get::<_, String>(1)?, &row.get::<_, String>(2)?); + // Copies are clamped to quantity_owned, so the card is always listed. + if let Some(card) = doc.cards.get_mut(&oracle_id) { + card.printings.insert(key, row.get(3)?); + } + } + let mut stmt = conn.prepare("SELECT id, uuid, name, format, commander_oracle_id, notes FROM decks")?; let mut rows = stmt.query([])?; let mut by_id: BTreeMap = BTreeMap::new(); @@ -381,8 +502,10 @@ pub fn read_doc(conn: &Connection) -> Result { ); } - let mut stmt = conn - .prepare("SELECT deck_id, oracle_id, quantity, is_commander, is_maybeboard FROM deck_cards")?; + let mut stmt = conn.prepare( + "SELECT deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id + FROM deck_cards", + )?; let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { let deck_id: i64 = row.get(0)?; @@ -396,11 +519,13 @@ pub fn read_doc(conn: &Connection) -> Result { quantity: row.get(2)?, commander: row.get::<_, i64>(3)? != 0, maybeboard: row.get::<_, i64>(4)? != 0, + printing: row.get(5)?, }, ); } } + doc.version = doc.required_version(); Ok(doc) } @@ -432,6 +557,22 @@ pub fn apply_doc(conn: &Connection, doc: &LibraryDoc) -> Result<()> { )?; } + // Copy groups are rebuilt wholesale: their row ids are local, and the + // document is the whole truth about them. + tx.execute("DELETE FROM library_card_copies", [])?; + for (oracle_id, entry) in &doc.cards { + for (key, qty) in &entry.printings { + let Some((scryfall_id, finish)) = parse_printing_key(key) else { + continue; // Hand-edited nonsense; nothing sensible to store. + }; + tx.execute( + "INSERT INTO library_card_copies(library_card_id, scryfall_id, finish, quantity) + SELECT id, ?2, ?3, ?4 FROM library_cards WHERE oracle_id = ?1", + rusqlite::params![oracle_id, scryfall_id, finish, qty], + )?; + } + } + // Decks are matched by uuid; anything else is a deck the document no longer // has. ON DELETE CASCADE clears its cards. { @@ -458,18 +599,20 @@ pub fn apply_doc(conn: &Connection, doc: &LibraryDoc) -> Result<()> { for (oracle_id, card) in &deck.cards { tx.execute( - "INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard) - VALUES (?1, ?2, ?3, ?4, ?5) + "INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(deck_id, oracle_id) DO UPDATE SET quantity = excluded.quantity, is_commander = excluded.is_commander, - is_maybeboard = excluded.is_maybeboard", + is_maybeboard = excluded.is_maybeboard, + preferred_scryfall_id = excluded.preferred_scryfall_id", rusqlite::params![ deck_id, oracle_id, card.quantity, card.commander as i64, - card.maybeboard as i64 + card.maybeboard as i64, + card.printing ], )?; } @@ -529,7 +672,7 @@ mod tests { for (id, qty) in cards { d.cards.insert( (*id).into(), - CardEntry { quantity: *qty, location: String::new() }, + CardEntry { quantity: *qty, ..Default::default() }, ); } for (uuid, name, cards) in decks { @@ -696,6 +839,87 @@ mod tests { assert!(report.conflicts.is_empty()); } + #[test] + fn a_quantity_edit_and_a_printing_added_elsewhere_both_survive() { + // Cards used to merge as one unit, so these two edits would have + // collided and one would have been lost. + let base = doc(&[("a", 2)], &[]); + let ours = doc(&[("a", 4)], &[]); + let mut theirs = base.clone(); + theirs.cards.get_mut("a").unwrap().printings.insert(printing_key("p1", "foil"), 1); + + let (merged, report) = merge3(&base, &ours, &theirs); + + let a = &merged.cards["a"]; + assert_eq!(a.quantity, 4); + assert_eq!(a.printings.get(&printing_key("p1", "foil")), Some(&1)); + assert!(report.conflicts.is_empty(), "different fields, no conflict"); + } + + #[test] + fn printings_recorded_on_each_side_merge_side_by_side() { + let base = doc(&[("a", 3)], &[]); + let mut ours = base.clone(); + let mut theirs = base.clone(); + ours.cards.get_mut("a").unwrap().printings.insert(printing_key("p1", "nonfoil"), 1); + theirs.cards.get_mut("a").unwrap().printings.insert(printing_key("p2", "foil"), 2); + + let (merged, report) = merge3(&base, &ours, &theirs); + + assert_eq!(merged.cards["a"].printings.len(), 2); + assert!(report.conflicts.is_empty()); + } + + #[test] + fn merged_printings_are_clamped_the_same_way_on_every_device() { + // Each side's own edit fits, but together they track 4 of 2 copies. + let base = doc(&[("a", 3)], &[]); + let mut ours = doc(&[("a", 2)], &[]); + ours.cards.get_mut("a").unwrap().printings.insert(printing_key("p1", "foil"), 1); + let mut theirs = base.clone(); + theirs.cards.get_mut("a").unwrap().printings.insert(printing_key("p2", "foil"), 3); + + let (phone, _) = merge3(&base, &ours, &theirs); + let (desktop, _) = merge3(&base, &theirs, &ours); + + assert_eq!(phone.cards["a"], desktop.cards["a"], "both devices must agree"); + let a = &phone.cards["a"]; + assert_eq!(a.quantity, 2); + assert_eq!(a.printings.values().sum::(), 2); + // Trimmed from the end of key order: p1 kept whole. + assert_eq!(a.printings.get(&printing_key("p1", "foil")), Some(&1)); + assert_eq!(a.printings.get(&printing_key("p2", "foil")), Some(&1)); + } + + #[test] + fn version_2_is_used_only_once_printing_data_exists() { + let mut d = doc(&[("a", 1)], &[("u1", "Fub", &[("a", 1)])]); + assert_eq!(d.required_version(), 1); + + d.decks.get_mut("u1").unwrap().cards.get_mut("a").unwrap().printing = Some("p1".into()); + assert_eq!(d.required_version(), 2); + + let mut d = doc(&[("a", 1)], &[]); + d.cards.get_mut("a").unwrap().printings.insert(printing_key("p1", "foil"), 1); + assert_eq!(d.required_version(), 2); + + // A merge result is stamped with what it needs. + let (merged, _) = merge3(&LibraryDoc::default(), &d, &LibraryDoc::default()); + assert_eq!(merged.version, 2); + // And this build reads it back, printing data intact. + let back = LibraryDoc::from_json(&merged.to_json().unwrap()).unwrap(); + assert_eq!(back, merged); + } + + #[test] + fn a_version_1_document_carries_no_printing_fields() { + // Older builds must see exactly what they always saw. + let d = doc(&[("a", 1)], &[("u1", "Fub", &[("a", 1)])]); + let text = d.to_json().unwrap(); + assert!(text.contains("\"version\": 1")); + assert!(!text.contains("printing")); + } + // --- document <-> database -------------------------------------------- fn catalog(dir: &Path, name: &str) -> Catalog { @@ -729,6 +953,40 @@ mod tests { assert_eq!(read_doc(&other.conn).unwrap(), before); } + #[test] + fn printings_round_trip_through_a_document() { + let tmp = tempfile::tempdir().unwrap(); + let c = catalog(tmp.path(), "lib"); + c.conn + .execute_batch( + "INSERT INTO ref.printings(scryfall_id, oracle_id, finishes) VALUES + ('p1', 'card-a', 'nonfoil,foil'), ('p2', 'card-a', 'nonfoil');", + ) + .unwrap(); + c.set_owned("card-a", 5).unwrap(); + c.set_library_copy("card-a", "p1", "foil", 2).unwrap(); + c.set_library_copy("card-a", "p2", "nonfoil", 1).unwrap(); + let deck = c.create_deck("Fub", None).unwrap(); + c.set_deck_card(deck.id, "card-a", 1, false, false).unwrap(); + c.set_deck_card_printing(deck.id, "card-a", Some("p2")).unwrap(); + + let before = read_doc(&c.conn).unwrap(); + assert_eq!(before.version, 2); + assert_eq!(before.cards["card-a"].printings.len(), 2); + let parsed = LibraryDoc::from_json(&before.to_json().unwrap()).unwrap(); + + let other = catalog(tmp.path(), "other"); + apply_doc(&other.conn, &parsed).unwrap(); + assert_eq!(read_doc(&other.conn).unwrap(), before); + assert_eq!(other.list_library_copies("card-a").unwrap().len(), 2); + + // Applying a document without them clears them. + let mut without = before.clone(); + without.cards.get_mut("card-a").unwrap().printings.clear(); + apply_doc(&other.conn, &without).unwrap(); + assert!(other.list_library_copies("card-a").unwrap().is_empty()); + } + #[test] fn applying_a_document_removes_what_it_no_longer_contains() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/mtgc-core/src/sync/remote.rs b/crates/mtgc-core/src/sync/remote.rs index c72e8a1..ca3e270 100644 --- a/crates/mtgc-core/src/sync/remote.rs +++ b/crates/mtgc-core/src/sync/remote.rs @@ -343,14 +343,14 @@ mod tests { fn set(&mut self, id: &str, qty: i64) { self.local .cards - .insert(id.into(), CardEntry { quantity: qty, location: String::new() }); + .insert(id.into(), CardEntry { quantity: qty, ..Default::default() }); } } fn doc(cards: &[(&str, i64)]) -> LibraryDoc { let mut d = LibraryDoc::default(); for (id, qty) in cards { - d.cards.insert((*id).into(), CardEntry { quantity: *qty, location: String::new() }); + d.cards.insert((*id).into(), CardEntry { quantity: *qty, ..Default::default() }); } d } diff --git a/crates/mtgc-server/tests/sync_e2e.rs b/crates/mtgc-server/tests/sync_e2e.rs index fb49b45..0575797 100644 --- a/crates/mtgc-server/tests/sync_e2e.rs +++ b/crates/mtgc-server/tests/sync_e2e.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use mtgc_core::sync::account::{redeem_code, server_info, start_url, Pkce}; use mtgc_core::sync::remote::{sync_cycle, HttpRemote, SyncBase, UNAUTHORIZED}; -use mtgc_core::sync::{apply_doc, merge_into, read_doc, CardEntry}; +use mtgc_core::sync::{apply_doc, merge_into, printing_key, read_doc, CardEntry}; use mtgc_server::auth::local::hash_password; use mtgc_server::{db, router, AppState, Config}; @@ -75,16 +75,54 @@ impl Device { fn set(&self, id: &str, qty: i64) { let conn = mtgc_core::db::open_library(&self.db).unwrap(); let mut doc = read_doc(&conn).unwrap(); - doc.cards.insert(id.into(), CardEntry { quantity: qty, location: String::new() }); + doc.cards.insert(id.into(), CardEntry { quantity: qty, ..Default::default() }); apply_doc(&conn, &doc).unwrap(); } + fn doc(&self) -> mtgc_core::sync::LibraryDoc { + read_doc(&mtgc_core::db::open_library(&self.db).unwrap()).unwrap() + } + fn qty(&self, id: &str) -> Option { let conn = mtgc_core::db::open_library(&self.db).unwrap(); read_doc(&conn).unwrap().cards.get(id).map(|c| c.quantity) } } +#[test] +fn printing_data_reaches_the_other_device_intact() { + // A version-2 document must be accepted by the server and come back out + // exactly as stored, or one device's tracked printings would vanish. + let (server, state) = spawn_server(); + let hash = hash_password("correct horse battery").unwrap(); + db::create_local_user(&state.db(), "bob", "Bob", &hash).unwrap(); + let dir = std::env::temp_dir().join(format!("mtgc-e2e-printings-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let phone_login = sign_in(&server, "bob", "correct horse battery"); + let desktop_login = sign_in(&server, "bob", "correct horse battery"); + let mut phone = Device::new(&dir, "phone", &server, &phone_login.token); + let mut desktop = Device::new(&dir, "desktop", &server, &desktop_login.token); + + { + let conn = mtgc_core::db::open_library(&phone.db).unwrap(); + let mut doc = read_doc(&conn).unwrap(); + let mut card = CardEntry { quantity: 3, ..Default::default() }; + card.printings.insert(printing_key("p1", "foil"), 2); + doc.cards.insert("a".into(), card); + apply_doc(&conn, &doc).unwrap(); + } + assert_eq!(phone.doc().version, 2); + assert!(phone.sync().unwrap().published); + desktop.sync().unwrap(); + + assert_eq!(desktop.doc(), phone.doc()); + assert_eq!(desktop.doc().cards["a"].printings[&printing_key("p1", "foil")], 2); + + std::fs::remove_dir_all(&dir).unwrap(); +} + #[test] fn two_devices_converge_through_the_real_server() { let (server, state) = spawn_server(); diff --git a/docs/printing-tracking-design.md b/docs/printing-tracking-design.md index e57bbb6..e80456a 100644 --- a/docs/printing-tracking-design.md +++ b/docs/printing-tracking-design.md @@ -1,6 +1,8 @@ # Design: optional per-printing tracking -Status: **not implemented** — design only, for a future session. +Status: **implemented.** The as-built decisions are in +[As built](#as-built) at the end, which takes precedence over the original +design below wherever the two differ. ## Context @@ -148,3 +150,42 @@ later is a compatible follow-up, not a redesign. `crates/mtgc-core/src/model.rs`, `src-tauri/src/commands.rs`, `src/api.ts`, `src/components.tsx`, `src/views/DeckView.tsx`, `src/views/LibraryBrowser.tsx`, `src/views/CardEntry.tsx`. + +## As built + +Where the implementation settled the open questions, or departed from the +design above: + +- **Migration** is `0006_printings.sql` (0005 had gone to deck uuids). It adds + `library_card_copies.quantity`, a unique index on + `(library_card_id, scryfall_id, finish)`, and `deck_cards.preferred_scryfall_id`. + Everything lives in `crates/mtgc-core/src/printings.rs`. +- **No `remove_copy(copy_id)`.** `set_library_copy(.., quantity)` with + `quantity <= 0` removes a group, mirroring `set_deck_card`. Row ids are local + and are rebuilt on every sync, so they would be a poor handle anyway. +- **Quantity rules (open question 3).** + - *Grow:* setting a group above the unspecified remainder raises + `quantity_owned` to fit. + - *Clamp:* lowering `quantity_owned` below the tracked total trims the + highest-id (most recently added) groups first. + - Lowering a group never lowers `quantity_owned`; those copies become + unspecified. + - All of this is pinned by tests in `printings.rs`. +- **Library thumbnails (open question 1)** always show the oracle default. + Only deck slots show a chosen printing. +- **Decklists (open question 2)** round-trip in this first cut, as + `(SET) NUM`, optionally followed by `*F*` / `*E*`. See `decklist.rs`. +- **UI.** There is no per-row button. A *Printings* section in the + click-preview info tile appears whenever the caller passes a + `PreviewContext`, and it opens a `PrintingPicker`. Both are in + `src/components.tsx`. +- **Sync, which postdates this design.** + - `CardEntry.printings` is keyed `"/"`, and + `DeckCardEntry.printing` holds the chosen printing. + - Cards now merge field by field, and their groups merge per key, so edits + to one card from two devices both survive. A merge result is clamped by + trimming from the end of key order, which comes out the same on every + device. + - A document is written as version 2 only once it holds printing data, so + older builds refuse it rather than dropping the data and syncing the loss. +- **Follow-up:** choosing a printing while entering cards on Add Cards. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 38be1f6..838e66c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -6,8 +6,8 @@ use std::path::PathBuf; use std::sync::Mutex; use mtgc_core::{ - CardRef, CardSuggestion, Deck, DeckEntry, LibraryCard, LibraryFacets, LibraryFilter, - LibraryStats, SuggestOptions, + CardRef, CardSuggestion, Deck, DeckEntry, LibraryCard, LibraryCopy, LibraryFacets, + LibraryFilter, LibraryStats, PrintingRef, SuggestOptions, }; use serde::Serialize; use tauri::{AppHandle, Emitter, State}; @@ -28,6 +28,10 @@ pub struct Status { /// Unix timestamp (seconds) of reference.db's last modification — i.e. /// when "Update card data" last completed. `None` if unavailable. pub reference_updated_at: Option, + /// Columns this build's card-data schema has that the installed card data + /// lacks (`"table.column"`). Non-empty means it was built by an older + /// version and should be updated; empty when current or not loaded. + pub reference_missing_columns: Vec, /// Whether this is a mobile build. The UI uses it to hide affordances that /// only make sense with an arbitrary-path filesystem. Reported from the build /// target rather than sniffed from the user agent so it can't disagree with @@ -62,6 +66,11 @@ fn status_of(s: &AppState) -> Status { reference_ready: s.catalog.is_some(), card_count: s.index.as_ref().map(|i| i.len()).unwrap_or(0), reference_updated_at, + reference_missing_columns: s + .catalog + .as_ref() + .map(|c| c.missing_reference_columns().to_vec()) + .unwrap_or_default(), is_mobile: cfg!(mobile), } } @@ -322,6 +331,54 @@ pub fn set_deck_card( .map_err(|e| e.to_string()) } +#[tauri::command] +pub fn list_printings(db: Db, oracle_id: String) -> Res> { + let s = db.lock().unwrap(); + s.catalog()?.list_printings(&oracle_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn list_library_copies(db: Db, oracle_id: String) -> Res> { + let s = db.lock().unwrap(); + s.catalog()? + .list_library_copies(&oracle_id) + .map_err(|e| e.to_string()) +} + +/// Returns the card's owned quantity afterwards, which grows when the copy +/// groups would otherwise exceed it. +#[tauri::command] +pub fn set_library_copy( + db: Db, + oracle_id: String, + scryfall_id: String, + finish: String, + quantity: i64, +) -> Res { + let s = db.lock().unwrap(); + s.catalog()? + .set_library_copy(&oracle_id, &scryfall_id, &finish, quantity) + .map_err(|e| e.to_string()) +} + +/// Returns the slot afterwards, so the UI can show the resulting art without +/// knowing how the default is chosen. +#[tauri::command] +pub fn set_deck_card_printing( + db: Db, + deck_id: i64, + oracle_id: String, + scryfall_id: Option, +) -> Res> { + let s = db.lock().unwrap(); + let catalog = s.catalog()?; + catalog + .set_deck_card_printing(deck_id, &oracle_id, scryfall_id.as_deref()) + .map_err(|e| e.to_string())?; + let contents = catalog.deck_contents(deck_id).map_err(|e| e.to_string())?; + Ok(contents.into_iter().find(|e| e.card.oracle_id == oracle_id)) +} + #[tauri::command] pub fn card_decks(db: Db, oracle_id: String) -> Res> { let s = db.lock().unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 63d85ef..425719b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -99,6 +99,10 @@ pub fn run() { commands::deck_contents, commands::suggest_cards, commands::card_decks, + commands::list_printings, + commands::list_library_copies, + commands::set_library_copy, + commands::set_deck_card_printing, commands::export_deck, commands::import_deck, commands::export_library_cards, diff --git a/src/App.tsx b/src/App.tsx index c91f2d7..4cbd90b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -342,6 +342,14 @@ export default function App() { // invited exactly what happened: a redundant download click before the // real status had even loaded. Distinguish the two explicitly. const statusLoaded = status !== null; + // Card data built by an older version lacks columns this one reads, so + // some features quietly stay off until it's updated. "Not now" is + // remembered against exactly what's missing: the notice comes back once a + // later version adds something more. + const missingColumns = status?.reference_missing_columns ?? []; + const outdatedKey = missingColumns.join(","); + const [dismissedOutdated, setDismissedOutdated] = usePersistedState("card-data-outdated-dismissed", ""); + const showOutdated = ready && missingColumns.length > 0 && !progress && dismissedOutdated !== outdatedKey; // Mirrors `ready` for the automatic-sync callbacks, which must stay stable // across renders (re-creating them would re-arm the listeners below and fire // a fresh sync every time card data finished loading). @@ -632,6 +640,30 @@ export default function App() { )} + {showOutdated && ( +
+ + Your card data was built by an older version of mtgc, so some newer features stay off until you + update it. + + + + {/* A full download is heavy on a phone; copying a fresh + reference.db over from a desktop is the cheaper route. */} + {status?.is_mobile && ( + + )} + + +
+ )} +
{!statusLoaded ? (
diff --git a/src/api.ts b/src/api.ts index 458b694..acd87ff 100644 --- a/src/api.ts +++ b/src/api.ts @@ -36,6 +36,32 @@ export interface DeckEntry extends CardRef { * analysis stats and library assignment totals. */ is_maybeboard: boolean; quantity_owned: number; + /** The printing whose art this slot shows; `image_uri` already reflects it. */ + preferred_scryfall_id: string | null; +} + +/** One printing of a card, for the printing picker and copy labels. */ +export interface PrintingRef { + scryfall_id: string; + set_code: string; // lowercase, e.g. "2x2" + collector_number: string; + lang: string; // "" on an older card database + finishes: string[]; // "nonfoil" | "foil" | "etched" + rarity: string; + image_uri: string; + back_image_uri: string; + price_usd: number; + released_at: string; // YYYY-MM-DD, "" on an older card database +} + +/** Owned copies pinned to one printing + finish. A subset of the card's + * `quantity_owned`; copies in no group are unspecified. */ +export interface LibraryCopy { + scryfall_id: string; + finish: string; + quantity: number; + /** Null when the printing has left the card database. */ + printing: PrintingRef | null; } /** @@ -48,8 +74,21 @@ export interface DeckEntry extends CardRef { export type PreviewCard = CardRef & { quantity_owned?: number; decks?: string[]; + preferred_scryfall_id?: string | null; }; +/** + * Where a click preview was opened from, when the caller lets it edit + * printings: the info tile then tracks owned copies by printing, and — for a + * deck slot — chooses which printing's art the slot shows. + */ +export interface PreviewContext { + /** Called after any printing change, so the caller can refetch. */ + onChanged: () => void; + /** The deck the previewed card is a slot of. */ + deckId?: number; +} + export interface Deck { id: number; name: string; @@ -149,6 +188,10 @@ export interface Status { reference_ready: boolean; card_count: number; reference_updated_at: number | null; // unix seconds + /** Columns the installed card data lacks ("table.column") because an older + * version built it. Non-empty means some features are degraded until the + * user updates card data. */ + reference_missing_columns: string[]; /** True on mobile builds. Used to hide affordances that depend on choosing an * arbitrary filesystem path for library.db — Android scoped storage has no * equivalent, and SQLite can't be backed by a content:// URI. */ @@ -407,6 +450,19 @@ export const api = { invoke("suggest_cards", { deckId, offset, options }), cardDecks: (oracleId: string) => invoke("card_decks", { oracleId }), + /** Every paper printing of a card, newest first. */ + listPrintings: (oracleId: string) => invoke("list_printings", { oracleId }), + listLibraryCopies: (oracleId: string) => + invoke("list_library_copies", { oracleId }), + /** Set how many owned copies are this printing + finish (0 removes the + * group). Resolves with the card's owned quantity, which grows to fit. */ + setLibraryCopy: (oracleId: string, scryfallId: string, finish: string, quantity: number) => + invoke("set_library_copy", { oracleId, scryfallId, finish, quantity }), + /** Choose which printing a deck slot shows; null restores the default. + * Resolves with the slot afterwards (its art reflecting the choice). */ + setDeckCardPrinting: (deckId: number, oracleId: string, scryfallId: string | null) => + invoke("set_deck_card_printing", { deckId, oracleId, scryfallId }), + exportDeck: (deckId: number, path: string) => invoke("export_deck", { deckId, path }), importDeck: (path: string, name: string) => invoke("import_deck", { path, name }), diff --git a/src/components.tsx b/src/components.tsx index fb9c1c5..6f9840e 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -9,15 +9,19 @@ import { useRef, useState, } from "react"; +import { createPortal } from "react-dom"; import { api, CardRef, COLORS, LEGALITY_FORMATS, + LibraryCopy, LibraryFilter, legalStatusFor, maskToLetters, PreviewCard, + PreviewContext, + PrintingRef, } from "./api"; import { useBackButton } from "./backButton"; @@ -476,7 +480,7 @@ export function ColorPips({ mask }: { mask: number }) { * clicking it opens a larger, centered preview (with a flip button for * double-faced cards). */ -export function CardThumb({ card }: { card: CardRef }) { +export function CardThumb({ card, preview }: { card: PreviewCard; preview?: PreviewContext }) { const { handlers } = useHoverPreview(); const { open } = useClickPreview(); if (!card.image_uri) { @@ -494,7 +498,7 @@ export function CardThumb({ card }: { card: CardRef }) { loading="lazy" onClick={(e) => { e.stopPropagation(); - open(card); + open(card, preview); }} {...handlers(card)} /> @@ -698,8 +702,9 @@ interface HoverApi { leave: () => void; } interface ClickApi { - open: (card: PreviewCard) => void; + open: (card: PreviewCard, ctx?: PreviewContext) => void; } +type ClickedState = { card: PreviewCard; ctx?: PreviewContext; showBack: boolean }; const HoverCtx = createContext(null); const ClickCtx = createContext(null); @@ -711,7 +716,7 @@ const ClickCtx = createContext(null); */ export function HoverPreviewProvider({ children }: { children: ReactNode }) { const [hover, setHover] = useState<{ card: CardRef; x: number; y: number } | null>(null); - const [clicked, setClicked] = useState<{ card: PreviewCard; showBack: boolean } | null>(null); + const [clicked, setClicked] = useState(null); const enter = useCallback((card: CardRef, e: React.MouseEvent) => { if (card.image_uri) setHover({ card, x: e.clientX, y: e.clientY }); @@ -721,10 +726,10 @@ export function HoverPreviewProvider({ children }: { children: ReactNode }) { }, []); const leave = useCallback(() => setHover(null), []); - const open = useCallback((card: PreviewCard) => { + const open = useCallback((card: PreviewCard, ctx?: PreviewContext) => { if (!card.image_uri) return; setHover(null); - setClicked({ card, showBack: false }); + setClicked({ card, ctx, showBack: false }); }, []); const close = useCallback(() => setClicked(null), []); const flip = useCallback(() => setClicked((c) => (c ? { ...c, showBack: !c.showBack } : c)), []); @@ -763,10 +768,11 @@ export function useHoverPreview() { }; } -/** Returns `open(card)` to trigger the larger click-to-preview modal. */ +/** Returns `open(card, ctx?)` to trigger the larger click-to-preview modal. + * Passing a `ctx` adds the Printings section to its info tile. */ export function useClickPreview() { const api = useContext(ClickCtx); - return { open: (card: PreviewCard) => api?.open(card) }; + return { open: (card: PreviewCard, ctx?: PreviewContext) => api?.open(card, ctx) }; } const PREVIEW_W = 320; @@ -867,7 +873,11 @@ function LegalityPills({ legalities }: { legalities: number }) { * type/mana/color, price, ownership context (when the caller had a * `LibraryCard`/`DeckEntry` to hand — see `PreviewCard`), and per-format * legality. */ -const CardInfoTile = forwardRef(function CardInfoTile({ card }, ref) { +const CardInfoTile = forwardRef< + HTMLDivElement, + { card: PreviewCard; ctx?: PreviewContext; onArtChange: (art: CardArt) => void } +>(function CardInfoTile({ card, ctx, onArtChange }, ref) { + const printings = !!ctx && card.quantity_owned !== undefined; return (
{card.name}
@@ -879,13 +889,17 @@ const CardInfoTile = forwardRef(function {card.price_usd > 0 && (
${card.price_usd.toFixed(2)}
)} - {card.quantity_owned !== undefined && ( -
- Owned - - {card.quantity_owned} cop{card.quantity_owned === 1 ? "y" : "ies"} - -
+ {printings ? ( + + ) : ( + card.quantity_owned !== undefined && ( +
+ Owned + + {card.quantity_owned} cop{card.quantity_owned === 1 ? "y" : "ies"} + +
+ ) )} {card.decks && card.decks.length > 0 && (
@@ -907,6 +921,254 @@ const CardInfoTile = forwardRef(function ); }); +type CardArt = Pick; + +const FINISH_LABELS: Record = { nonfoil: "Nonfoil", foil: "Foil", etched: "Etched" }; + +/** "2X2 #315", the way decklists and binders name a printing. */ +function printingLabel(p: PrintingRef): string { + return `${p.set_code.toUpperCase()} #${p.collector_number}`; +} + +/** + * The click preview's printing controls (shown when it was opened with a + * `PreviewContext`): owned copies broken down by printing + finish, and — for + * a deck slot — which printing's art the slot shows. Takes the place of the + * plain "Owned" row, since growing a copy group can change that total. + */ +function PrintingsSection({ + card, + ctx, + onArtChange, +}: { + card: PreviewCard; + ctx: PreviewContext; + onArtChange: (art: CardArt) => void; +}) { + const [owned, setOwned] = useState(card.quantity_owned ?? 0); + const [copies, setCopies] = useState([]); + const [printings, setPrintings] = useState(null); + const [preferred, setPreferred] = useState(card.preferred_scryfall_id ?? null); + const [picking, setPicking] = useState<"deck" | "copy" | null>(null); + const [error, setError] = useState(null); + + useEffect(() => { + let live = true; + api + .listLibraryCopies(card.oracle_id) + .then((c) => live && setCopies(c)) + .catch(() => {}); + api + .listPrintings(card.oracle_id) + .then((p) => live && setPrintings(p)) + .catch(() => live && setPrintings([])); + return () => { + live = false; + }; + }, [card.oracle_id]); + + const tracked = copies.reduce((n, c) => n + c.quantity, 0); + const shown = preferred ? printings?.find((p) => p.scryfall_id === preferred) : undefined; + + async function run(action: () => Promise) { + setError(null); + try { + await action(); + ctx.onChanged(); + } catch (e) { + setError(String(e)); + } + } + + const setCopy = (scryfallId: string, finish: string, quantity: number) => + run(async () => { + setOwned(await api.setLibraryCopy(card.oracle_id, scryfallId, finish, quantity)); + setCopies(await api.listLibraryCopies(card.oracle_id)); + }); + + const showPrinting = (scryfallId: string | null) => + run(async () => { + const entry = await api.setDeckCardPrinting(ctx.deckId!, card.oracle_id, scryfallId); + setPreferred(scryfallId); + if (entry) onArtChange({ image_uri: entry.image_uri, back_image_uri: entry.back_image_uri }); + }); + + function onPick(p: PrintingRef, finish: string) { + const mode = picking; + setPicking(null); + if (mode === "deck") { + showPrinting(p.scryfall_id); + } else { + const existing = copies.find((c) => c.scryfall_id === p.scryfall_id && c.finish === finish); + setCopy(p.scryfall_id, finish, (existing?.quantity ?? 0) + 1); + } + } + + return ( + <> + {ctx.deckId !== undefined && ( +
+ Shown in this deck +
+ + {!preferred ? "Default printing" : shown ? printingLabel(shown) : "Unavailable printing"} + + + + + {preferred && } + +
+
+ )} +
+ Owned + + {owned} cop{owned === 1 ? "y" : "ies"} + {tracked > 0 && ( + + {" "} + · {tracked} by printing · {Math.max(0, owned - tracked)} unspecified + + )} + + {copies.map((c) => ( +
+ {c.printing?.image_uri ? ( + + ) : ( + + )} + + {c.printing ? printingLabel(c.printing) : "Unavailable printing"} + · {FINISH_LABELS[c.finish] ?? c.finish} + + + setCopy(c.scryfall_id, c.finish, c.quantity - 1)} + onIncrement={() => setCopy(c.scryfall_id, c.finish, c.quantity + 1)} + /> +
+ ))} + + + + {error &&
{error}
} +
+ {picking && printings && ( + setPicking(null)} + /> + )} + + ); +} + +/** + * A grid of a card's printings to pick from. With `chooseFinish`, each + * printing also offers its finishes (clicking the art takes the first). + * + * Opens above the click preview, which closes on any Escape it hears on + * `window` — so this listens in the capture phase and stops the event there, + * and only the picker closes. + */ +function PrintingPicker({ + name, + printings, + chooseFinish, + current, + onPick, + onCancel, +}: { + name: string; + printings: PrintingRef[]; + chooseFinish: boolean; + current: string | null; + onPick: (p: PrintingRef, finish: string) => void; + onCancel: () => void; +}) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + e.stopPropagation(); + onCancel(); + }; + window.addEventListener("keydown", onKey, true); + return () => window.removeEventListener("keydown", onKey, true); + }, [onCancel]); + useBackButton(onCancel); + + return createPortal( +
{ + e.stopPropagation(); + onCancel(); + }} + > +
e.stopPropagation()}> +
+

{chooseFinish ? `Add a printing of ${name}` : `Show ${name} as`}

+ +
+
+ {printings.map((p) => { + const finishes = p.finishes.length > 0 ? p.finishes : ["nonfoil"]; + const meta = [ + p.released_at.slice(0, 4), + p.rarity && p.rarity[0].toUpperCase() + p.rarity.slice(1), + p.lang && p.lang !== "en" ? p.lang.toUpperCase() : "", + p.price_usd > 0 ? `$${p.price_usd.toFixed(2)}` : "", + ] + .filter(Boolean) + .join(" · "); + return ( +
+ +
{printingLabel(p)}
+ {meta &&
{meta}
} + {chooseFinish && finishes.length > 1 && ( +
+ {finishes.map((f) => ( + + ))} +
+ )} +
+ ); + })} +
+
+
, + document.body, + ); +} + type PreviewPane = "card" | "info"; // Compact-mode toolbar height and the modal's flex gap — keep in sync with @@ -946,11 +1208,15 @@ function ClickPreviewModal({ onClose, onFlip, }: { - state: { card: PreviewCard; showBack: boolean }; + state: ClickedState; onClose: () => void; onFlip: () => void; }) { - const { card, showBack } = state; + const { ctx, showBack } = state; + // A printing just chosen for this deck slot: show its art right away + // rather than the art the preview was opened with. + const [art, setArt] = useState(null); + const card: PreviewCard = art ? { ...state.card, ...art } : state.card; const hasBack = !!card.back_image_uri; const backdropRef = useRef(null); const modalRef = useRef(null); @@ -1072,7 +1338,7 @@ function ClickPreviewModal({ )}
- + {compact && (
@@ -1455,8 +1721,9 @@ export function CardTile({ footer, selected, rotated, + preview, }: { - card: CardRef; + card: PreviewCard; corner?: ReactNode; /** Rendered top-left, e.g. a selection checkbox (see `selected`). */ cornerLeft?: ReactNode; @@ -1469,6 +1736,8 @@ export function CardTile({ * is also true, so mixed results (e.g. a set with both Sieges and normal * cards) don't spin the ones that are already right-side up. */ rotated?: boolean; + /** Lets the click preview edit this card's printings. */ + preview?: PreviewContext; }) { const { open } = useClickPreview(); const showRotated = !!rotated && card.horizontal; @@ -1482,7 +1751,7 @@ export function CardTile({ src={card.image_uri} alt={card.name} loading="lazy" - onClick={() => open(card)} + onClick={() => open(card, preview)} /> ) : ( {card.name} open(card)} + onClick={() => open(card, preview)} /> ) ) : ( diff --git a/src/styles.css b/src/styles.css index a3d2c6c..bb2a6a0 100644 --- a/src/styles.css +++ b/src/styles.css @@ -672,6 +672,27 @@ input[type="checkbox"] { border-bottom: 1px solid var(--border-soft); font-size: 13px; } +/* Card data from an older version: same strip as the ingest bar, tinted as + a warning. Wraps its buttons under the message on narrow screens. */ +.outdated-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 16px; + padding: 8px 18px; + background: rgba(224, 163, 74, 0.12); + border-bottom: 1px solid rgba(224, 163, 74, 0.35); + font-size: 13px; +} +.outdated-bar-message { + flex: 1 1 260px; + color: var(--text); +} +.outdated-bar-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} .ingest-bar-head { display: flex; align-items: center; @@ -3299,3 +3320,82 @@ tbody tr:hover { font-size: 22px; } } + +/* ============================================================ + Printings: the click preview's per-printing controls, and the picker + ============================================================ */ +.printing-row { + display: flex; + align-items: center; + gap: var(--space-2); + min-width: 0; +} +.printing-row-thumb { + width: 28px; + height: 39px; + flex-shrink: 0; + object-fit: cover; + border-radius: 3px; + background: var(--bg-elev); +} +.printing-row-label { + min-width: 0; + font-size: 13px; + overflow-wrap: anywhere; +} +.printing-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.printing-actions button { + padding: 2px 10px; + font-size: 12px; +} +/* Above the click preview (450) it opens from. */ +.printing-picker-backdrop { + z-index: 500; +} +.printing-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(130px, 40vw), 1fr)); + gap: var(--space-3); +} +.printing-option { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} +.printing-option-art { + display: block; + width: 100%; + padding: 0; + border: 2px solid transparent; + border-radius: var(--radius-card); + background: none; + cursor: pointer; +} +.printing-option-art:hover { + border-color: var(--accent-hover); + background: none; +} +.printing-option.selected .printing-option-art { + border-color: var(--accent); +} +.printing-option-art img { + display: block; + width: 100%; + aspect-ratio: 488 / 680; + object-fit: cover; + border-radius: calc(var(--radius-card) - 2px); +} +.printing-option-label { + font-size: 13px; + font-weight: 600; + color: var(--text-strong); +} +.printing-option-meta { + font-size: 12px; + color: var(--muted); +} diff --git a/src/views/DeckView.tsx b/src/views/DeckView.tsx index e3105c7..368402d 100644 --- a/src/views/DeckView.tsx +++ b/src/views/DeckView.tsx @@ -1,6 +1,6 @@ import { open, save } from "@tauri-apps/plugin-dialog"; import { useEffect, useMemo, useRef, useState, useCallback } from "react"; -import { api, CardRef, COLORS, Deck, DeckEntry, DeckImportResult, legalStatusFor } from "../api"; +import { api, CardRef, COLORS, Deck, DeckEntry, DeckImportResult, legalStatusFor, PreviewContext } from "../api"; import { CardSearchBox, CardThumb, @@ -169,7 +169,15 @@ function computeDeckStats(contents: DeckEntry[]): DeckStats { } /** A prominent, art-forward display for a deck's commander (Commander/Brawl). */ -function CommanderHero({ card, onClear }: { card: DeckEntry; onClear: () => void }) { +function CommanderHero({ + card, + onClear, + preview, +}: { + card: DeckEntry; + onClear: () => void; + preview?: PreviewContext; +}) { const { handlers } = useHoverPreview(); const { open } = useClickPreview(); return ( @@ -179,7 +187,7 @@ function CommanderHero({ card, onClear }: { card: DeckEntry; onClear: () => void className="commander-hero-img" src={card.image_uri} alt={card.name} - onClick={() => open(card)} + onClick={() => open(card, preview)} {...handlers(card)} /> ) : ( @@ -740,6 +748,10 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) { const deckStats = useMemo(() => computeDeckStats(contents), [contents]); const deckLegality = useMemo(() => computeDeckLegality(deck, contents), [deck, contents]); + // Lets the click preview track copies and choose each slot's printing. + const preview: PreviewContext | undefined = + selected === null ? undefined : { deckId: selected, onChanged: loadContents }; + function deckTiles(entries: DeckEntry[]) { return (
@@ -747,6 +759,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) { @@ -803,7 +816,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) { /> - + @@ -961,7 +974,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
- +
)} diff --git a/src/views/LibraryBrowser.tsx b/src/views/LibraryBrowser.tsx index 4c72a5f..4ef8ff1 100644 --- a/src/views/LibraryBrowser.tsx +++ b/src/views/LibraryBrowser.tsx @@ -1,6 +1,14 @@ import { open, save } from "@tauri-apps/plugin-dialog"; import { useEffect, useRef, useState } from "react"; -import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter, LibraryImportResult } from "../api"; +import { + api, + Deck, + LibraryCard, + LibraryFacets, + LibraryFilter, + LibraryImportResult, + PreviewContext, +} from "../api"; import { CardThumb, CardTile, @@ -330,6 +338,9 @@ export function LibraryBrowser({ ); + // Lets the click preview track which printings the copies are. + const preview: PreviewContext = { onChanged: () => setRefreshKey((k) => k + 1) }; + function libTable(cards: LibraryCard[]) { return ( @@ -356,7 +367,7 @@ export function LibraryBrowser({ />
- + @@ -399,6 +410,7 @@ export function LibraryBrowser({